Bladeren bron

feat: 新增广告组管理功能及JDK21兼容性修复

- 新增广告组同步、查询API及前端页面
- 修复腾讯广告SDK在JDK21下的模块访问权限问题
- 完善数据库表结构和Mapper配置
pudongliang 5 maanden geleden
bovenliggende
commit
74a5c8ca93

+ 6 - 0
.gitignore

@@ -39,3 +39,9 @@ logs/
 ### OS ###
 .DS_Store
 Thumbs.db
+
+
+node_modules/
+target
+logs
+

+ 18 - 0
pom.xml

@@ -153,6 +153,24 @@
                             <artifactId>lombok</artifactId>
                         </exclude>
                     </excludes>
+                    <!-- 为腾讯广告SDK的Google Guice添加JVM参数 -->
+                    <jvmArguments>
+                        --add-opens java.base/java.lang=ALL-UNNAMED
+                        --add-opens java.base/java.util=ALL-UNNAMED
+                        --add-opens java.base/java.lang.reflect=ALL-UNNAMED
+                    </jvmArguments>
+                </configuration>
+            </plugin>
+            
+            <!-- Maven Surefire Plugin - 为Java 21添加JVM参数 -->
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <argLine>
+                        --add-opens java.base/java.lang=ALL-UNNAMED
+                        --add-opens java.base/java.util=ALL-UNNAMED
+                    </argLine>
                 </configuration>
             </plugin>
         </plugins>

+ 17 - 12
portal/src/layout/index.vue

@@ -39,22 +39,27 @@
 
 <script setup>
 import { computed } from 'vue'
-import { useRoute } from 'vue-router'
-import { Monitor, Lock } from '@element-plus/icons-vue'
+import { useRoute, useRouter } from 'vue-router'
 
 const route = useRoute()
+const router = useRouter()
 
-// 菜单路由
-const menuRoutes = [
-  {
-    path: '/dashboard',
-    meta: { title: '控制台', icon: 'Monitor' }
-  },
-  {
-    path: '/auth',
-    meta: { title: 'OAuth授权管理', icon: 'Lock' }
+// 从路由配置中获取菜单路由
+const menuRoutes = computed(() => {
+  const routes = router.getRoutes()
+  // 找到根路由(Layout组件)下的所有子路由
+  const rootRoute = routes.find(r => r.path === '/')
+  if (rootRoute && rootRoute.children) {
+    // 过滤出有meta.title的路由作为菜单项
+    return rootRoute.children
+      .filter(child => child.meta && child.meta.title)
+      .map(child => ({
+        path: child.path.startsWith('/') ? child.path : `/${child.path}`,
+        meta: child.meta
+      }))
   }
-]
+  return []
+})
 
 // 当前激活的菜单
 const activeMenu = computed(() => {

+ 6 - 0
portal/src/router/index.js

@@ -14,6 +14,12 @@ const routes = [
         meta: { title: 'OAuth授权管理', icon: 'Lock' }
       },
       {
+        path: 'adgroup',
+        name: 'Adgroup',
+        component: () => import('@/views/adgroup/index.vue'),
+        meta: { title: '广告组管理', icon: 'DataBoard' }
+      },
+      {
         path: 'dashboard',
         name: 'Dashboard',
         component: () => import('@/views/dashboard/index.vue'),

+ 273 - 0
portal/src/views/adgroup/index.vue

@@ -0,0 +1,273 @@
+<template>
+  <div class="adgroup-container">
+    <el-card class="box-card">
+      <template #header>
+        <div class="card-header">
+          <span class="card-title">广告组数据同步与查询</span>
+        </div>
+      </template>
+
+      <!-- 同步功能区 -->
+      <div class="sync-section">
+        <h3>数据同步</h3>
+        <el-form :model="syncForm" inline label-width="120px">
+          <el-form-item label="账户ID">
+            <el-input 
+              v-model="syncForm.accountId" 
+              placeholder="请输入账户ID,例如:54151573"
+              style="width: 300px"
+            />
+          </el-form-item>
+          
+          <el-form-item label="时间范围">
+            <el-date-picker
+              v-model="syncForm.timeRange"
+              type="datetimerange"
+              range-separator="至"
+              start-placeholder="开始时间"
+              end-placeholder="结束时间"
+              format="YYYY-MM-DD HH:mm:ss"
+              value-format="x"
+              style="width: 400px"
+            />
+          </el-form-item>
+          
+          <el-form-item>
+            <el-button 
+              type="primary" 
+              :loading="syncing" 
+              @click="handleSync"
+            >
+              开始同步
+            </el-button>
+            <el-button @click="resetSyncForm">重置</el-button>
+          </el-form-item>
+        </el-form>
+      </div>
+
+      <el-divider />
+
+      <!-- 查询功能区 -->
+      <div class="query-section">
+        <h3>广告组列表</h3>
+        <el-form :model="queryForm" inline>
+          <el-form-item label="账户ID">
+            <el-input 
+              v-model="queryForm.accountId" 
+              placeholder="请输入账户ID(可选)"
+              style="width: 200px"
+              clearable
+            />
+          </el-form-item>
+          
+          <el-form-item>
+            <el-button type="primary" icon="Search" @click="handleQuery">查询</el-button>
+            <el-button icon="Refresh" @click="handleReset">重置</el-button>
+          </el-form-item>
+        </el-form>
+
+        <!-- 数据表格 -->
+        <el-table 
+          :data="tableData" 
+          style="width: 100%; margin-top: 20px"
+          v-loading="loading"
+          border
+        >
+          <el-table-column prop="adgroupId" label="广告组ID" width="150" />
+          <el-table-column prop="adgroupName" label="广告组名称" width="200" />
+          <el-table-column prop="accountId" label="账户ID" width="120" />
+          <el-table-column prop="configuredStatus" label="配置状态" width="120" />
+          <el-table-column prop="dailyBudget" label="日预算(分)" width="120" />
+          <el-table-column prop="marketingScene" label="营销场景" width="150" />
+          <el-table-column prop="createdTime" label="创建时间" width="110">
+            <template #default="{ row }">
+              {{ formatTimestamp(row.createdTime) }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="lastModifiedTime" label="最后修改时间" width="160">
+            <template #default="{ row }">
+              {{ formatTimestamp(row.lastModifiedTime) }}
+            </template>
+          </el-table-column>
+        </el-table>
+
+        <!-- 分页 -->
+        <el-pagination
+          v-model:current-page="queryForm.page"
+          v-model:page-size="queryForm.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+          style="margin-top: 20px; justify-content: flex-end"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import request from '@/utils/request'
+
+// 同步表单
+const syncForm = reactive({
+  accountId: '54151573',
+  timeRange: []
+})
+
+// 查询表单
+const queryForm = reactive({
+  accountId: '',
+  page: 1,
+  pageSize: 20
+})
+
+// 状态
+const syncing = ref(false)
+const loading = ref(false)
+const tableData = ref([])
+const total = ref(0)
+
+// 同步数据
+const handleSync = async () => {
+  if (!syncForm.accountId) {
+    ElMessage.warning('请输入账户ID')
+    return
+  }
+
+  syncing.value = true
+  
+  const params = {
+    accountId: syncForm.accountId
+  }
+  
+  // 如果选择了时间范围
+  if (syncForm.timeRange && syncForm.timeRange.length === 2) {
+    params.startTime = Math.floor(syncForm.timeRange[0] / 1000) // 转为秒
+    params.endTime = Math.floor(syncForm.timeRange[1] / 1000)
+  }
+
+  try {
+    const res = await request.post('/adgroups/sync', null, { params })
+    if (res.success) {
+      ElMessage.success(res.message || '同步成功')
+      // 同步成功后刷新列表
+      queryForm.accountId = syncForm.accountId
+      await handleQuery()
+    } else {
+      ElMessage.error(res.message || '同步失败')
+    }
+  } catch (error) {
+    ElMessage.error('同步失败:' + (error.message || '未知错误'))
+  } finally {
+    syncing.value = false
+  }
+}
+
+// 重置同步表单
+const resetSyncForm = () => {
+  syncForm.accountId = '54151573'
+  syncForm.timeRange = []
+}
+
+// 查询数据
+const handleQuery = async () => {
+  loading.value = true
+  
+  const params = {
+    page: queryForm.page,
+    pageSize: queryForm.pageSize
+  }
+  
+  if (queryForm.accountId) {
+    params.accountId = queryForm.accountId
+  }
+
+  try {
+    const res = await request.get('/adgroups/list', { params })
+    if (res.success) {
+      tableData.value = res.list || []
+      total.value = res.total || 0
+    } else {
+      ElMessage.error(res.message || '查询失败')
+    }
+  } catch (error) {
+    ElMessage.error('查询失败:' + (error.message || '未知错误'))
+  } finally {
+    loading.value = false
+  }
+}
+
+// 重置查询
+const handleReset = () => {
+  queryForm.accountId = ''
+  queryForm.page = 1
+  queryForm.pageSize = 20
+  handleQuery()
+}
+
+// 分页大小改变
+const handleSizeChange = () => {
+  handleQuery()
+}
+
+// 页码改变
+const handleCurrentChange = () => {
+  handleQuery()
+}
+
+// 格式化时间戳
+const formatTimestamp = (timestamp) => {
+  if (!timestamp) return '-'
+  const date = new Date(timestamp * 1000)
+  return date.toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit'
+  })
+}
+
+// 组件挂载时查询数据
+onMounted(() => {
+  handleQuery()
+})
+</script>
+
+<style scoped>
+.adgroup-container {
+  padding: 20px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.card-title {
+  font-size: 18px;
+  font-weight: 600;
+}
+
+.sync-section,
+.query-section {
+  margin-bottom: 20px;
+}
+
+h3 {
+  margin-bottom: 15px;
+  color: #409eff;
+  font-size: 16px;
+  font-weight: 600;
+}
+
+:deep(.el-form-item) {
+  margin-bottom: 15px;
+}
+</style>

+ 88 - 0
src/main/java/com/moka/gdtauto/controller/TencentAdsAdgroupController.java

@@ -0,0 +1,88 @@
+package com.moka.gdtauto.controller;
+
+import com.moka.gdtauto.entity.TencentAdsAdgroup;
+import com.moka.gdtauto.service.TencentAdsAdgroupService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 腾讯广告组API控制器
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/adgroups")
+@RequiredArgsConstructor
+@Tag(name = "广告组管理", description = "腾讯广告组数据同步与查询")
+public class TencentAdsAdgroupController {
+
+    private final TencentAdsAdgroupService adgroupService;
+
+    /**
+     * 同步广告组数据
+     */
+    @Operation(summary = "同步广告组数据", description = "从腾讯广告平台同步指定账户的广告组数据到数据库")
+    @PostMapping("/sync")
+    public Map<String, Object> syncAdgroups(
+            @Parameter(description = "账户ID", required = true, example = "54151573")
+            @RequestParam String accountId,
+            @Parameter(description = "开始时间戳(秒)", example = "1708520382")
+            @RequestParam(required = false) Long startTime,
+            @Parameter(description = "结束时间戳(秒)", example = "1708568262")
+            @RequestParam(required = false) Long endTime) {
+        
+        Map<String, Object> result = new HashMap<>();
+        try {
+            int count = adgroupService.syncAdgroups(accountId, startTime, endTime);
+            result.put("success", true);
+            result.put("count", count);
+            result.put("message", "同步成功,共同步" + count + "条广告组数据");
+        } catch (Exception e) {
+            log.error("同步广告组数据失败", e);
+            result.put("success", false);
+            result.put("message", "同步失败:" + e.getMessage());
+        }
+        return result;
+    }
+
+    /**
+     * 查询广告组列表
+     */
+    @Operation(summary = "查询广告组列表", description = "分页查询广告组数据")
+    @GetMapping("/list")
+    public Map<String, Object> listAdgroups(
+            @Parameter(description = "账户ID(可选)", example = "54151573")
+            @RequestParam(required = false) String accountId,
+            @Parameter(description = "页码", example = "1")
+            @RequestParam(defaultValue = "1") Integer page,
+            @Parameter(description = "每页数量", example = "20")
+            @RequestParam(defaultValue = "20") Integer pageSize) {
+        
+        Map<String, Object> result = new HashMap<>();
+        try {
+            List<TencentAdsAdgroup> list = adgroupService.listAdgroups(accountId, page, pageSize);
+            long total = adgroupService.countAdgroups(accountId);
+            
+            result.put("success", true);
+            result.put("list", list);
+            result.put("total", total);
+            result.put("page", page);
+            result.put("pageSize", pageSize);
+        } catch (Exception e) {
+            log.error("查询广告组列表失败", e);
+            result.put("success", false);
+            result.put("message", "查询失败:" + e.getMessage());
+        }
+        return result;
+    }
+}

+ 80 - 0
src/main/java/com/moka/gdtauto/entity/TencentAdsAdgroup.java

@@ -0,0 +1,80 @@
+package com.moka.gdtauto.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * 腾讯广告组实体
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@Data
+@TableName("tencent_ads_adgroup")
+public class TencentAdsAdgroup {
+
+    /**
+     * 主键ID
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 账户ID
+     */
+    private String accountId;
+
+    /**
+     * 广告组ID
+     */
+    private Long adgroupId;
+
+    /**
+     * 广告组名称
+     */
+    private String adgroupName;
+
+    /**
+     * 配置状态
+     */
+    private String configuredStatus;
+
+    /**
+     * 日预算(单位:分)
+     */
+    private Long dailyBudget;
+
+    /**
+     * 创建时间(时间戳)
+     */
+    private Long createdTime;
+
+    /**
+     * 营销场景
+     */
+    private String marketingScene;
+
+    /**
+     * 最后修改时间(时间戳)
+     */
+    private Long lastModifiedTime;
+
+    /**
+     * 是否已删除:0-否,1-是
+     */
+    private Integer isDeleted;
+
+    /**
+     * 记录创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 记录更新时间
+     */
+    private LocalDateTime updateTime;
+}

+ 26 - 0
src/main/java/com/moka/gdtauto/mapper/TencentAdsAdgroupMapper.java

@@ -0,0 +1,26 @@
+package com.moka.gdtauto.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.moka.gdtauto.entity.TencentAdsAdgroup;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 腾讯广告组Mapper
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@Mapper
+public interface TencentAdsAdgroupMapper extends BaseMapper<TencentAdsAdgroup> {
+    
+    /**
+     * 批量插入或更新广告组数据
+     * 使用 INSERT INTO ... ON DUPLICATE KEY UPDATE
+     * 
+     * @param list 广告组列表
+     * @return 影响行数
+     */
+    int batchInsertOrUpdate(List<TencentAdsAdgroup> list);
+}

+ 87 - 10
src/main/java/com/moka/gdtauto/service/TencentAdsAccountService.java

@@ -1,17 +1,20 @@
 package com.moka.gdtauto.service;
 
 import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.tencent.ads.exception.TencentAdsResponseException;
+import com.tencent.ads.exception.TencentAdsSDKException;
+import com.tencent.ads.model.v3.FilteringStruct;
+import com.tencent.ads.model.v3.AdgroupsGetResponseData;
 import com.tencent.ads.v3.TencentAds;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
+import java.util.List;
+
 /**
  * 腾讯广告账户API服务
  * 
- * 注意:由于SDK的API容器方法(如account(), campaigns()等)在当前版本中可能不可用,
- * 这里提供基础框架,具体API调用需要根据实际SDK版本调整。
- * 
  * @author moka
  * @since 2026-02-03
  */
@@ -34,11 +37,85 @@ public class TencentAdsAccountService {
         return clientFactory.getTencentAds(accountId);
     }
 
-    // TODO: 根据实际SDK版本实现具体的API调用方法
-    // 示例:
-    // public Object getAccountInfo(String accountId) throws Exception {
-    //     TencentAds tencentAds = clientFactory.getTencentAds(accountId);
-    //     // 调用SDK提供的实际API方法
-    //     return result;
-    // }
+    /**
+     * 获取广告组列表
+     * 
+     * @param accountId 广告主账户ID
+     * @param page 页码,最小值1,最大值100,默认1
+     * @param pageSize 每页数量,最小值1,最大值100,默认10
+     * @return 广告组列表响应数据
+     * @throws Exception API异常
+     */
+    public AdgroupsGetResponseData getAdgroups(String accountId, Long page, Long pageSize) 
+            throws TencentAdsResponseException, TencentAdsSDKException, Exception {
+        log.info("开始获取广告组列表,accountId={}, page={}, pageSize={}", accountId, page, pageSize);
+        
+        // 获取TencentAds实例
+        TencentAds tencentAds = clientFactory.getTencentAds(accountId);
+        
+        // 调用SDK的adgroups().adgroupsGet()方法
+        Long accountIdLong = Long.parseLong(accountId);
+        List<FilteringStruct> filtering = null; // 不传过滤条件,获取全部
+        Boolean isDeleted = false; // 不包含已删除的广告组
+        List<String> fields = null; // 返回所有字段
+        String paginationMode = null; // 使用默认分页模式
+        String cursor = null; // 游标分页用,普通分页不需要
+        
+        AdgroupsGetResponseData response = tencentAds.adgroups()
+            .adgroupsGet(accountIdLong, filtering, page, pageSize, isDeleted, fields, paginationMode, cursor);
+        
+        log.info("获取广告组列表成功,返回{}条数据", 
+            response != null && response.getList() != null ? response.getList().size() : 0);
+        
+        return response;
+    }
+
+    /**
+     * 获取广告组列表(使用默认分页参数)
+     * 默认获取第1页,10条数据
+     * 
+     * @param accountId 广告主账户ID
+     * @return 广告组列表响应数据
+     * @throws Exception API异常
+     */
+    public AdgroupsGetResponseData getAdgroups(String accountId) 
+            throws TencentAdsResponseException, TencentAdsSDKException, Exception {
+        return getAdgroups(accountId, 1L, 10L);
+    }
+
+    /**
+     * 获取广告组列表(带过滤条件和字段)
+     * 
+     * @param accountId 广告主账户ID
+     * @param page 页码
+     * @param pageSize 每页数量
+     * @param filtering 过滤条件
+     * @param fields 返回字段
+     * @return 广告组列表响应数据
+     * @throws Exception API异常
+     */
+    public AdgroupsGetResponseData getAdgroupsWithFiltering(
+            String accountId, Long page, Long pageSize, 
+            List<FilteringStruct> filtering, List<String> fields) 
+            throws TencentAdsResponseException, TencentAdsSDKException, Exception {
+        log.info("开始获取广告组列表,accountId={}, page={}, pageSize={}, filtering={}", 
+            accountId, page, pageSize, filtering);
+        
+        // 获取TencentAds实例
+        TencentAds tencentAds = clientFactory.getTencentAds(accountId);
+        
+        // 调用SDK的adgroups().adgroupsGet()方法
+        Long accountIdLong = Long.parseLong(accountId);
+        Boolean isDeleted = false;
+        String paginationMode = null;
+        String cursor = null;
+        
+        AdgroupsGetResponseData response = tencentAds.adgroups()
+            .adgroupsGet(accountIdLong, filtering, page, pageSize, isDeleted, fields, paginationMode, cursor);
+        
+        log.info("获取广告组列表成功,返回{}条数据", 
+            response != null && response.getList() != null ? response.getList().size() : 0);
+        
+        return response;
+    }
 }

+ 208 - 0
src/main/java/com/moka/gdtauto/service/TencentAdsAdgroupService.java

@@ -0,0 +1,208 @@
+package com.moka.gdtauto.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.moka.gdtauto.entity.TencentAdsAdgroup;
+import com.moka.gdtauto.mapper.TencentAdsAdgroupMapper;
+import com.tencent.ads.model.v3.AdgroupsGetListStruct;
+import com.tencent.ads.model.v3.AdgroupsGetResponseData;
+import com.tencent.ads.model.v3.FilterOperator;
+import com.tencent.ads.model.v3.FilteringStruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 腾讯广告组服务
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TencentAdsAdgroupService extends ServiceImpl<TencentAdsAdgroupMapper, TencentAdsAdgroup> {
+
+    private final TencentAdsAccountService accountService;
+    private final TencentAdsAdgroupMapper adgroupMapper;
+
+    /**
+     * 同步指定账户的广告组数据
+     * 
+     * @param accountId 账户ID
+     * @param startTime 开始时间戳(可选)
+     * @param endTime 结束时间戳(可选)
+     * @return 同步的广告组数量
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public int syncAdgroups(String accountId, Long startTime, Long endTime) throws Exception {
+        log.info("开始同步广告组数据,accountId={}, startTime={}, endTime={}", accountId, startTime, endTime);
+        
+        int totalSynced = 0;
+        Long page = 1L;
+        Long pageSize = 100L; // 每页100条
+        boolean hasMore = true;
+
+        while (hasMore) {
+            // 构建过滤条件
+            List<FilteringStruct> filtering = buildFiltering(startTime, endTime);
+            
+            // 构建返回字段
+            List<String> fields = buildFields();
+            
+            // 调用API获取数据
+            AdgroupsGetResponseData response = accountService.getAdgroupsWithFiltering(
+                accountId, page, pageSize, filtering, fields);
+            
+            if (response == null || response.getList() == null || response.getList().isEmpty()) {
+                hasMore = false;
+                break;
+            }
+            
+            // 批量保存数据
+            List<TencentAdsAdgroup> adgroups = convertToEntity(response.getList(), accountId);
+            batchSaveOrUpdate(adgroups);
+            
+            totalSynced += adgroups.size();
+            log.info("已同步第{}页,本页{}条数据", page, adgroups.size());
+            
+            // 判断是否还有下一页
+            if (response.getPageInfo() != null && 
+                page >= response.getPageInfo().getTotalPage()) {
+                hasMore = false;
+            }
+            
+            page++;
+        }
+        
+        log.info("同步完成,共同步{}条广告组数据", totalSynced);
+        return totalSynced;
+    }
+
+    /**
+     * 构建过滤条件
+     */
+    private List<FilteringStruct> buildFiltering(Long startTime, Long endTime) {
+        if (startTime == null && endTime == null) {
+            return null;
+        }
+        
+        List<FilteringStruct> filtering = new ArrayList<>();
+        
+        if (startTime != null) {
+            FilteringStruct filter = new FilteringStruct();
+            filter.setField("last_modified_time");
+            filter.setOperator(FilterOperator.GREATER_EQUALS);
+            filter.setValues(List.of(String.valueOf(startTime)));
+            filtering.add(filter);
+        }
+        
+        if (endTime != null) {
+            FilteringStruct filter = new FilteringStruct();
+            filter.setField("last_modified_time");
+            filter.setOperator(FilterOperator.LESS_EQUALS);
+            filter.setValues(List.of(String.valueOf(endTime)));
+            filtering.add(filter);
+        }
+        
+        return filtering;
+    }
+
+    /**
+     * 构建返回字段
+     */
+    private List<String> buildFields() {
+        return List.of(
+            "adgroup_id",
+            "adgroup_name",
+            "configured_status",
+            "daily_budget",
+            "created_time",
+            "marketing_scene",
+            "last_modified_time",
+            "is_deleted"
+        );
+    }
+
+    /**
+     * 转换为实体对象
+     */
+    private List<TencentAdsAdgroup> convertToEntity(List<AdgroupsGetListStruct> apiList, String accountId) {
+        List<TencentAdsAdgroup> result = new ArrayList<>();
+        
+        for (AdgroupsGetListStruct api : apiList) {
+            TencentAdsAdgroup entity = new TencentAdsAdgroup();
+            entity.setAccountId(accountId);
+            entity.setAdgroupId(api.getAdgroupId());
+            entity.setAdgroupName(api.getAdgroupName());
+            entity.setConfiguredStatus(api.getConfiguredStatus() != null ? 
+                api.getConfiguredStatus().toString() : null);
+            entity.setDailyBudget(api.getDailyBudget());
+            entity.setCreatedTime(api.getCreatedTime());
+            entity.setMarketingScene(api.getMarketingScene() != null ? 
+                api.getMarketingScene().toString() : null);
+            entity.setLastModifiedTime(api.getLastModifiedTime());
+            // 注意:SDK可能没有isDeleted字段,设置为0
+            entity.setIsDeleted(0);
+            
+            result.add(entity);
+        }
+        
+        return result;
+    }
+
+    /**
+     * 批量保存或更新
+     * 使用 INSERT INTO ... ON DUPLICATE KEY UPDATE
+     */
+    private void batchSaveOrUpdate(List<TencentAdsAdgroup> adgroups) {
+        if (adgroups == null || adgroups.isEmpty()) {
+            return;
+        }
+        
+        // 使用MyBatis XML的批量插入方法
+        adgroupMapper.batchInsertOrUpdate(adgroups);
+        log.info("批量保存/更新{}条数据", adgroups.size());
+    }
+
+    /**
+     * 查询广告组列表
+     * 
+     * @param accountId 账户ID(可选)
+     * @param page 页码
+     * @param pageSize 每页数量
+     * @return 广告组列表
+     */
+    public List<TencentAdsAdgroup> listAdgroups(String accountId, Integer page, Integer pageSize) {
+        LambdaQueryWrapper<TencentAdsAdgroup> wrapper = new LambdaQueryWrapper<>();
+        
+        if (accountId != null && !accountId.isEmpty()) {
+            wrapper.eq(TencentAdsAdgroup::getAccountId, accountId);
+        }
+        
+        wrapper.orderByDesc(TencentAdsAdgroup::getLastModifiedTime)
+               .last("LIMIT " + ((page - 1) * pageSize) + ", " + pageSize);
+        
+        return list(wrapper);
+    }
+
+    /**
+     * 统计广告组数量
+     * 
+     * @param accountId 账户ID(可选)
+     * @return 广告组数量
+     */
+    public long countAdgroups(String accountId) {
+        LambdaQueryWrapper<TencentAdsAdgroup> wrapper = new LambdaQueryWrapper<>();
+        
+        if (accountId != null && !accountId.isEmpty()) {
+            wrapper.eq(TencentAdsAdgroup::getAccountId, accountId);
+        }
+        
+        return count(wrapper);
+    }
+}

+ 1 - 1
src/main/java/com/moka/gdtauto/service/TencentAdsAuthService.java

@@ -208,7 +208,7 @@ public class TencentAdsAuthService extends ServiceImpl<TencentAdsAuthMapper, Ten
     }
 
     /**
-     * 根据账户ID获取授权信息
+     * 根据账户ID查询授权信息
      * 
      * @param accountId 账户ID
      * @return 授权信息

+ 43 - 0
src/main/resources/mapper/TencentAdsAdgroupMapper.xml

@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.moka.gdtauto.mapper.TencentAdsAdgroupMapper">
+
+    <!-- 批量插入或更新广告组数据 -->
+    <insert id="batchInsertOrUpdate" parameterType="java.util.List">
+        INSERT INTO tencent_ads_adgroup (
+            account_id,
+            adgroup_id,
+            adgroup_name,
+            configured_status,
+            daily_budget,
+            created_time,
+            marketing_scene,
+            last_modified_time,
+            is_deleted
+        )
+        VALUES
+        <foreach collection="list" item="item" separator=",">
+            (
+                #{item.accountId},
+                #{item.adgroupId},
+                #{item.adgroupName},
+                #{item.configuredStatus},
+                #{item.dailyBudget},
+                #{item.createdTime},
+                #{item.marketingScene},
+                #{item.lastModifiedTime},
+                #{item.isDeleted}
+            )
+        </foreach>
+        ON DUPLICATE KEY UPDATE
+            account_id = VALUES(account_id),
+            adgroup_name = VALUES(adgroup_name),
+            configured_status = VALUES(configured_status),
+            daily_budget = VALUES(daily_budget),
+            created_time = VALUES(created_time),
+            marketing_scene = VALUES(marketing_scene),
+            last_modified_time = VALUES(last_modified_time),
+            is_deleted = VALUES(is_deleted)
+    </insert>
+
+</mapper>

+ 56 - 19
src/main/resources/sql/init.sql

@@ -1,19 +1,56 @@
--- 腾讯广告OAuth授权表
-CREATE TABLE IF NOT EXISTS `tencent_ads_auth` (
-  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
-  `account_id` VARCHAR(64) NOT NULL COMMENT '账户ID(腾讯广告账户ID)',
-  `access_token` VARCHAR(512) NOT NULL COMMENT '访问令牌',
-  `refresh_token` VARCHAR(512) NOT NULL COMMENT '刷新令牌',
-  `access_token_expires_at` DATETIME NOT NULL COMMENT '访问令牌过期时间',
-  `refresh_token_expires_at` DATETIME NOT NULL COMMENT '刷新令牌过期时间',
-  `scope` VARCHAR(512) DEFAULT NULL COMMENT '授权范围',
-  `status` TINYINT(1) NOT NULL DEFAULT '1' COMMENT '状态:0-失效,1-有效',
-  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
-  `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
-  `deleted` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标识:0-未删除,1-已删除',
-  PRIMARY KEY (`id`),
-  KEY `idx_account_id` (`account_id`),
-  KEY `idx_status` (`status`),
-  KEY `idx_access_token_expires_at` (`access_token_expires_at`),
-  KEY `idx_create_time` (`create_time`)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯广告OAuth授权表';
+-- auto-generated definition
+create table tencent_ads_auth
+(
+    id                       bigint auto_increment comment '主键ID'
+        primary key,
+    account_id               varchar(64)                          not null comment '账户ID(腾讯广告账户ID)',
+    access_token             varchar(512)                         not null comment '访问令牌',
+    refresh_token            varchar(512)                         not null comment '刷新令牌',
+    access_token_expires_at  datetime                             not null comment '访问令牌过期时间',
+    refresh_token_expires_at datetime                             not null comment '刷新令牌过期时间',
+    scope                    varchar(512)                         null comment '授权范围',
+    status                   tinyint(1) default 1                 not null comment '状态:0-失效,1-有效',
+    create_time              datetime   default CURRENT_TIMESTAMP not null comment '创建时间',
+    update_time              datetime   default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
+    deleted                  tinyint(1) default 0                 not null comment '逻辑删除标识:0-未删除,1-已删除',
+    constraint uidx_account_id
+        unique (account_id)
+)
+    comment '腾讯广告OAuth授权表';
+
+create index idx_access_token_expires_at
+    on tencent_ads_auth (access_token_expires_at);
+
+create index idx_create_time
+    on tencent_ads_auth (create_time);
+
+create index idx_status
+    on tencent_ads_auth (status);
+
+-- 腾讯广告组表
+create table tencent_ads_adgroup
+(
+    id                  bigint auto_increment comment '主键ID'
+        primary key,
+    account_id          varchar(64)                          not null comment '账户ID',
+    adgroup_id          bigint                               not null comment '广告组ID',
+    adgroup_name        varchar(256)                         null comment '广告组名称',
+    configured_status   varchar(32)                          null comment '配置状态',
+    daily_budget        bigint                               null comment '日预算(单位:分)',
+    created_time        bigint                               null comment '创建时间(时间戳)',
+    marketing_scene     varchar(64)                          null comment '营销场景',
+    last_modified_time  bigint                               null comment '最后修改时间(时间戳)',
+    is_deleted          tinyint(1) default 0                 null comment '是否已删除:0-否,1-是',
+    create_time         datetime   default CURRENT_TIMESTAMP not null comment '记录创建时间',
+    update_time         datetime   default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '记录更新时间',
+    constraint uidx_account_adgroup
+        unique (account_id, adgroup_id)
+)
+    comment '腾讯广告组表';
+
+create index idx_last_modified_time
+    on tencent_ads_adgroup (last_modified_time);
+
+create index idx_configured_status
+    on tencent_ads_adgroup (configured_status);
+

+ 70 - 0
src/test/java/com/moka/gdtauto/integration/AdgroupsApiManualTest.java

@@ -0,0 +1,70 @@
+package com.moka.gdtauto.integration;
+
+import com.alibaba.fastjson2.JSON;
+import com.moka.gdtauto.GdtAutoApplication;
+import com.moka.gdtauto.service.TencentAdsAccountService;
+import com.tencent.ads.model.v3.AdgroupsGetResponseData;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+/**
+ * 广告组API手动测试
+ * 用于手动调用腾讯广告API并查看返回结果
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@SpringBootTest(classes = GdtAutoApplication.class)
+@ActiveProfiles("local")
+class AdgroupsApiManualTest {
+
+    @Autowired
+    private TencentAdsAccountService accountService;
+
+    private static final String TEST_ACCOUNT_ID = "54151573";
+
+    @Test
+    @DisplayName("手动测试-获取广告组列表第1页")
+    void testGetAdgroupsFirstPage() {
+        System.out.println("\n========================================");
+        System.out.println("开始获取广告组列表");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 调用SDK获取第1页,10条数据
+            System.out.println("正在调用腾讯广告API...");
+            AdgroupsGetResponseData response = accountService.getAdgroups(TEST_ACCOUNT_ID, 1L, 10L);
+
+            // 将返回结果JSON化输出
+            System.out.println("\n========== API返回结果(JSON格式) ==========");
+            String jsonResult = JSON.toJSONString(response);
+            System.out.println(jsonResult);
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null && response.getList() != null) {
+                System.out.println("✅ 成功获取广告组列表");
+                System.out.println("📊 返回数据条数: " + response.getList().size());
+                
+                if (response.getPageInfo() != null) {
+                    System.out.println("📄 分页信息:");
+                    System.out.println("   - 当前页: " + response.getPageInfo().getPage());
+                    System.out.println("   - 每页数量: " + response.getPageInfo().getPageSize());
+                    System.out.println("   - 总数: " + response.getPageInfo().getTotalNumber());
+                }
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+}

+ 201 - 0
src/test/java/com/moka/gdtauto/integration/TencentAdsAccountIntegrationTest.java

@@ -0,0 +1,201 @@
+package com.moka.gdtauto.integration;
+
+import com.moka.gdtauto.GdtAutoApplication;
+import com.moka.gdtauto.entity.TencentAdsAuth;
+import com.moka.gdtauto.mapper.TencentAdsAuthMapper;
+import com.moka.gdtauto.service.TencentAdsAccountService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * 腾讯广告账户API集成测试
+ * 使用真实数据库进行测试
+ * 
+ * 注意:由于 Java 21 和腾讯广告SDK的兼容性问题,
+ * 此集成测试主要验证数据库操作和业务逻辑,
+ * 不直接调用腾讯广告SDK的API(避免 Google Guice 反射问题)
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@SpringBootTest(
+    classes = GdtAutoApplication.class,
+    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
+)
+@ActiveProfiles("local")
+@Transactional  // 测试后自动回滚,不影响数据库
+class TencentAdsAccountIntegrationTest {
+
+    @Autowired
+    private TencentAdsAccountService accountService;
+
+    @Autowired
+    private TencentAdsAuthMapper authMapper;
+
+    private static final String TEST_ACCOUNT_ID = "54151573";
+
+    @BeforeEach
+    void setUp() {
+        System.out.println("\n========================================");
+        System.out.println("准备集成测试环境");
+        System.out.println("========================================");
+        
+        // 确保测试账户的授权信息存在
+        TencentAdsAuth existingAuth = authMapper.selectById(TEST_ACCOUNT_ID);
+        if (existingAuth == null) {
+            System.out.println("测试账户不存在,创建测试授权信息...");
+            createTestAuth(TEST_ACCOUNT_ID);
+        } else {
+            System.out.println("测试账户已存在:" + TEST_ACCOUNT_ID);
+        }
+    }
+
+    @Test
+    @DisplayName("测试查询账户授权信息是否存在")
+    void testAccountAuthExists() {
+        System.out.println("\n========== 开始测试:查询账户授权信息 ==========");
+        
+        // When - 查询账户授权信息
+        TencentAdsAuth auth = authMapper.selectById(TEST_ACCOUNT_ID);
+        
+        // Then - 验证结果
+        System.out.println("\n验证授权信息...");
+        assertNotNull(auth, "授权信息不应为空");
+        assertEquals(TEST_ACCOUNT_ID, auth.getAccountId(), "账户ID应该匹配");
+        assertEquals(1, auth.getStatus(), "账户状态应该为有效");
+        assertNotNull(auth.getAccessToken(), "Access Token不应为空");
+        assertNotNull(auth.getRefreshToken(), "Refresh Token不应为空");
+        
+        System.out.println("账户ID: " + auth.getAccountId());
+        System.out.println("状态: " + (auth.getStatus() == 1 ? "有效" : "失效"));
+        System.out.println("Access Token过期时间: " + auth.getAccessTokenExpiresAt());
+        System.out.println("Refresh Token过期时间: " + auth.getRefreshTokenExpiresAt());
+        
+        System.out.println("========== 测试完成 ==========\n");
+    }
+
+    @Test
+    @DisplayName("测试创建和查询新账户授权信息")
+    void testCreateAndQueryAuth() {
+        System.out.println("\n========== 开始测试:创建新账户授权 ==========");
+        
+        // Given - 创建新的测试账户
+        String newAccountId = "test_account_" + System.currentTimeMillis();
+        TencentAdsAuth newAuth = createTestAuth(newAccountId);
+        
+        // When - 插入数据库
+        System.out.println("插入新账户:" + newAccountId);
+        authMapper.insert(newAuth);
+        System.out.println("插入成功");
+        
+        // Then - 查询并验证
+        System.out.println("\n查询刚插入的数据...");
+        TencentAdsAuth queried = authMapper.selectById(newAccountId);
+        
+        assertNotNull(queried, "应该能查询到刚插入的数据");
+        assertEquals(newAccountId, queried.getAccountId());
+        assertEquals(newAuth.getAccessToken(), queried.getAccessToken());
+        assertEquals(1, queried.getStatus());
+        
+        System.out.println("查询成功,账户ID: " + queried.getAccountId());
+        System.out.println("========== 测试完成 ==========\n");
+    }
+
+    @Test
+    @DisplayName("测试更新账户授权信息")
+    void testUpdateAuth() {
+        System.out.println("\n========== 开始测试:更新账户授权 ==========");
+        
+        // Given - 创建测试账户
+        String accountId = "update_test_" + System.currentTimeMillis();
+        TencentAdsAuth auth = createTestAuth(accountId);
+        authMapper.insert(auth);
+        System.out.println("创建测试账户:" + accountId);
+        
+        String originalToken = auth.getAccessToken();
+        
+        // When - 更新 access token
+        String newToken = "new_token_" + System.currentTimeMillis();
+        auth.setAccessToken(newToken);
+        auth.setAccessTokenExpiresAt(LocalDateTime.now().plusHours(3));
+        
+        System.out.println("\n更新 Access Token...");
+        authMapper.updateById(auth);
+        
+        // Then - 查询验证
+        TencentAdsAuth updated = authMapper.selectById(accountId);
+        
+        assertNotNull(updated);
+        assertNotEquals(originalToken, updated.getAccessToken(), "Token应该已更新");
+        assertEquals(newToken, updated.getAccessToken());
+        
+        System.out.println("更新成功");
+        System.out.println("原Token: " + originalToken.substring(0, 20) + "...");
+        System.out.println("新Token: " + updated.getAccessToken().substring(0, 20) + "...");
+        System.out.println("========== 测试完成 ==========\n");
+    }
+
+    @Test
+    @DisplayName("测试删除账户授权信息")
+    void testDeleteAuth() {
+        System.out.println("\n========== 开始测试:删除账户授权 ==========");
+        
+        // Given - 创建测试账户
+        String accountId = "delete_test_" + System.currentTimeMillis();
+        TencentAdsAuth auth = createTestAuth(accountId);
+        authMapper.insert(auth);
+        System.out.println("创建测试账户:" + accountId);
+        
+        // When - 执行逻辑删除
+        System.out.println("\n执行逻辑删除...");
+        authMapper.deleteById(accountId);
+        
+        // Then - 验证删除(应该查不到)
+        TencentAdsAuth deleted = authMapper.selectById(accountId);
+        
+        assertNull(deleted, "逻辑删除后应该查询不到数据");
+        
+        System.out.println("逻辑删除成功,查询结果为 null");
+        System.out.println("========== 测试完成 ==========\n");
+    }
+
+    @Test
+    @DisplayName("测试getTencentAdsInstance方法-账户存在")
+    void testGetTencentAdsInstance_AccountExists() {
+        System.out.println("\n========== 开始测试:获取TencentAds实例 ==========");
+        
+        // When & Then - 验证方法不抛异常(不实际调用SDK)
+        assertDoesNotThrow(() -> {
+            System.out.println("验证账户服务可以正常注入和使用");
+            assertNotNull(accountService, "账户服务应该已注入");
+            System.out.println("账户服务注入成功");
+        });
+        
+        System.out.println("========== 测试完成 ==========\n");
+    }
+
+    /**
+     * 创建测试用的授权对象
+     */
+    private TencentAdsAuth createTestAuth(String accountId) {
+        TencentAdsAuth auth = new TencentAdsAuth();
+        auth.setAccountId(accountId);
+        auth.setAccessToken("test_access_token_" + System.currentTimeMillis());
+        auth.setRefreshToken("test_refresh_token_" + System.currentTimeMillis());
+        auth.setAccessTokenExpiresAt(LocalDateTime.now().plusHours(2));
+        auth.setRefreshTokenExpiresAt(LocalDateTime.now().plusDays(30));
+        auth.setScope("ads_management,ads_insights");
+        auth.setStatus(1);
+        auth.setDeleted(0);
+        return auth;
+    }
+}