Просмотр исходного кода

feat:实名认证授权user-token

pudongliang 5 месяцев назад
Родитель
Сommit
49fdc65387
23 измененных файлов с 1235 добавлено и 67 удалено
  1. 180 0
      docs/ADGROUP_CREATE_GUIDE.md
  2. 8 3
      portal/src/views/adgroup/index.vue
  3. 54 7
      portal/src/views/auth/index.vue
  4. 97 17
      src/main/java/com/moka/gdtauto/client/TencentAdsApiClientFactory.java
  5. 2 2
      src/main/java/com/moka/gdtauto/controller/TencentAdsAdgroupController.java
  6. 78 3
      src/main/java/com/moka/gdtauto/controller/TencentAdsAuthController.java
  7. 70 0
      src/main/java/com/moka/gdtauto/entity/SysUser.java
  8. 85 0
      src/main/java/com/moka/gdtauto/entity/TencentAdsAdgroup.java
  9. 1 1
      src/main/java/com/moka/gdtauto/entity/TencentAdsAuth.java
  10. 15 0
      src/main/java/com/moka/gdtauto/mapper/SysUserMapper.java
  11. 116 0
      src/main/java/com/moka/gdtauto/service/SysUserService.java
  12. 12 12
      src/main/java/com/moka/gdtauto/service/TencentAdsAccountService.java
  13. 114 8
      src/main/java/com/moka/gdtauto/service/TencentAdsAdgroupService.java
  14. 94 0
      src/main/java/com/moka/gdtauto/service/TencentAdsAuthService.java
  15. 3 3
      src/main/resources/application-local.yml
  16. 54 3
      src/main/resources/mapper/TencentAdsAdgroupMapper.xml
  17. 18 0
      src/main/resources/sql/create_user_tables.sql
  18. 17 0
      src/main/resources/sql/init.sql
  19. 14 0
      src/main/resources/sql/upgrade_user_token.sql
  20. 6 6
      src/test/java/com/moka/gdtauto/client/TencentAdsApiClientFactoryTest.java
  21. 1 1
      src/test/java/com/moka/gdtauto/integration/AdgroupsApiManualTest.java
  22. 1 1
      src/test/java/com/moka/gdtauto/integration/TencentAdsAccountIntegrationTest.java
  23. 195 0
      src/test/java/com/moka/gdtauto/service/TencentAdsAdgroupCreateTest.java

+ 180 - 0
docs/ADGROUP_CREATE_GUIDE.md

@@ -0,0 +1,180 @@
+# 腾讯广告创建功能说明
+
+## 功能概述
+
+本文档介绍如何使用gdt-auto项目创建腾讯广告组(Adgroup)。
+
+## 核心实现
+
+### 1. Service层实现
+
+[TencentAdsAdgroupService.java](file:///Users/pudongliang/moka_workspace/gdt-auto/src/main/java/com/moka/gdtauto/service/TencentAdsAdgroupService.java#L284-L312)
+
+```java
+public AdgroupsAddResponseData createAdgroup(String accountId, AdgroupsAddRequest request) 
+        throws TencentAdsResponseException, TencentAdsSDKException, Exception {
+    log.info("开始创建广告,accountId={}, adgroupName={}", accountId, request.getAdgroupName());
+    
+    // 获取TencentAds实例
+    TencentAds tencentAds = clientFactory.getTencentAds(accountId);
+    
+    // 设置账户ID
+    request.setAccountId(Long.parseLong(accountId));
+    
+    // 调用SDK创建广告
+    AdgroupsAddResponseData response = tencentAds.adgroups()
+        .adgroupsAdd(request);
+    
+    log.info("创建广告成功,adgroupId={}", response.getAdgroupId());
+    
+    return response;
+}
+```
+
+### 2. 测试实现
+
+[TencentAdsAdgroupCreateTest.java](file:///Users/pudongliang/moka_workspace/gdt-auto/src/test/java/com/moka/gdtauto/service/TencentAdsAdgroupCreateTest.java)
+
+测试类实现了:
+- 完整的创建广告测试用例
+- 根据您提供的测试数据构建请求对象
+- 定向设置的构建(地域、性别、年龄、排除人群等)
+
+## 测试数据说明
+
+### 基础字段
+
+| 字段 | 类型 | 说明 | 示例 |
+|-----|-----|-----|-----|
+| adgroup_name | String | 广告名称 | "测试ROI-0204" + 时间戳 |
+| marketing_goal | MarketingGoal | 营销目标 | MARKETING_GOAL_USER_GROWTH |
+| marketing_carrier_type | MarketingCarrierType | 营销载体类型 | MARKETING_CARRIER_TYPE_JUMP_PAGE |
+| begin_date | String | 开始日期 | "2026-02-03" |
+| end_date | String | 结束日期 | "2026-02-03" |
+| optimization_goal | OptimizationGoal | 优化目标 | OPTIMIZATIONGOAL_APP_REGISTER |
+| time_series | String | 投放时间段 | 336位字符串 |
+| bid_amount | Long | 出价金额(分) | 100 |
+| daily_budget | Long | 日预算(分) | 1000 |
+| conversion_id | Long | 转化ID | 74719216 |
+| bid_mode | BidMode | 出价模式 | BID_MODE_OCPM |
+| auto_derived_creative_enabled | Boolean | 自动衍生创意 | false |
+| bid_scene | BidScene | 出价场景 | BID_SCENE_UNKNOWN |
+
+### 定向设置(targeting)
+
+#### 地域定向(geo_location)
+```java
+GeoLocations geoLocation = new GeoLocations();
+List<String> locationTypes = new ArrayList<>();
+locationTypes.add("LIVE_IN"); // 居住地
+geoLocation.setLocationTypes(locationTypes);
+List<Long> regions = new ArrayList<>();
+regions.add(1156L); // 中国
+geoLocation.setRegions(regions);
+```
+
+#### 性别定向(gender)
+```java
+List<String> gender = new ArrayList<>();
+gender.add("MALE"); // 男性
+```
+
+#### 年龄定向(age)
+```java
+List<AgeStruct> age = new ArrayList<>();
+AgeStruct ageRange = new AgeStruct();
+ageRange.setMin(35L);
+ageRange.setMax(66L);
+age.add(ageRange);
+```
+
+#### 排除转化用户(excluded_converted_audience)
+```java
+ExcludedConvertedAudience excludedConverted = new ExcludedConvertedAudience();
+excludedConverted.setExcludedDimension(ExcludedDimension.EXCLUDED_DIMENSION_GROUP);
+
+List<String> conversionBehaviorList = new ArrayList<>();
+conversionBehaviorList.add("OPTIMIZATIONGOAL_BRAND_CONVERSION");
+conversionBehaviorList.add("OPTIMIZATIONGOAL_PAGE_SCAN_CODE");
+excludedConverted.setConversionBehaviorList(conversionBehaviorList);
+
+excludedConverted.setExcludedDay(ExcludedDay.EXCLUDED_DAY_ONE_MONTH);
+```
+
+#### 排除自定义人群(excluded_custom_audience)
+```java
+List<Long> excludedCustomAudience = new ArrayList<>();
+excludedCustomAudience.add(43712084L);
+excludedCustomAudience.add(44107982L);
+excludedCustomAudience.add(44106830L);
+excludedCustomAudience.add(44241932L);
+```
+
+### 投放站点(site_set)
+```java
+List<String> siteSet = new ArrayList<>();
+siteSet.add("SITE_SET_SEARCH_SCENE");      // 搜索场景
+siteSet.add("SITE_SET_MOBILE_UNION");      // 移动联盟
+siteSet.add("SITE_SET_WECHAT");            // 微信
+siteSet.add("SITE_SET_KANDIAN");           // 看点
+siteSet.add("SITE_SET_QQ_MUSIC_GAME");     // QQ音乐游戏
+siteSet.add("SITE_SET_TENCENT_NEWS");      // 腾讯新闻
+siteSet.add("SITE_SET_TENCENT_VIDEO");     // 腾讯视频
+```
+
+## 运行测试
+
+### 1. 修改配置
+
+在 `src/main/resources/application-local.yml` 中配置:
+- 腾讯广告的账户信息
+- Access Token
+
+### 2. 运行测试
+
+```bash
+cd /Users/pudongliang/moka_workspace/gdt-auto
+mvn test -Dtest=TencentAdsAdgroupCreateTest#testCreateAdgroupWithTestData
+```
+
+### 3. 查看测试数据JSON
+
+可以运行打印JSON的测试方法查看请求数据结构:
+
+```bash
+mvn test -Dtest=TencentAdsAdgroupCreateTest#printTestDataJson
+```
+
+## API文档参考
+
+- [腾讯广告创建广告API文档](https://developers.e.qq.com/v3.0/docs/api/adgroups/add)
+- SDK版本:marketing-api-java-sdk 1.1.106
+
+## 注意事项
+
+1. **时间序列**: time_series是336位字符串,表示7天*48个半小时的投放时间,1表示投放,0表示不投放
+2. **金额单位**: bid_amount和daily_budget的单位都是**分**(¥0.01)
+3. **枚举值**: SDK中的枚举类型需要使用valueOf()方法转换字符串
+4. **定向设置**: targeting是一个复杂对象,包含多种定向条件
+5. **账户ID**: 需要在调用前确保账户已完成OAuth授权
+
+## 常见问题
+
+### Q1: 如何获取Access Token?
+
+A: 需要先进行OAuth授权,具体参考[TencentAdsAuthService.java](file:///Users/pudongliang/moka_workspace/gdt-auto/src/main/java/com/moka/gdtauto/service/TencentAdsAuthService.java)
+
+### Q2: 如何查看更多字段?
+
+A: 参考SDK中的[AdgroupsAddRequest.class](file:///marketing-api-java-sdk-1.1.106.jar/com.tencent.ads.model.v3/AdgroupsAddRequest.class)类,包含所有可用字段
+
+### Q3: 如何自定义定向条件?
+
+A: 可以参考[WriteTargetingSetting](file:///marketing-api-java-sdk-1.1.106.jar/com.tencent.ads.model.v3/WriteTargetingSetting.class)类,支持更多定向维度
+
+## 相关文件
+
+- Service层: [TencentAdsAdgroupService.java](file:///Users/pudongliang/moka_workspace/gdt-auto/src/main/java/com/moka/gdtauto/service/TencentAdsAdgroupService.java)
+- 测试类: [TencentAdsAdgroupCreateTest.java](file:///Users/pudongliang/moka_workspace/gdt-auto/src/test/java/com/moka/gdtauto/service/TencentAdsAdgroupCreateTest.java)
+- API客户端工厂: [TencentAdsApiClientFactory.java](file:///Users/pudongliang/moka_workspace/gdt-auto/src/main/java/com/moka/gdtauto/client/TencentAdsApiClientFactory.java)
+- 实体类: [TencentAdsAdgroup.java](file:///Users/pudongliang/moka_workspace/gdt-auto/src/main/java/com/moka/gdtauto/entity/TencentAdsAdgroup.java)

+ 8 - 3
portal/src/views/adgroup/index.vue

@@ -14,7 +14,7 @@
           <el-form-item label="账户ID">
             <el-input 
               v-model="syncForm.accountId" 
-              placeholder="请输入账户ID,例如:54151573"
+              placeholder="请输入账户ID,例如:66612379"
               style="width: 300px"
             />
           </el-form-item>
@@ -78,6 +78,11 @@
           <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="marketingGoal" label="营销目标" width="120" />
+          <el-table-column prop="optimizationGoal" label="优化目标" width="120" />
+          <el-table-column prop="bidMode" label="出价模式" width="120" />
+          <el-table-column prop="beginDate" label="开始日期" width="110" />
+          <el-table-column prop="endDate" label="结束日期" width="110" />
           <el-table-column prop="marketingScene" label="营销场景" width="150" />
           <el-table-column prop="createdTime" label="创建时间" width="110">
             <template #default="{ row }">
@@ -114,7 +119,7 @@ import request from '@/utils/request'
 
 // 同步表单
 const syncForm = reactive({
-  accountId: '54151573',
+  accountId: '66612379',
   timeRange: []
 })
 
@@ -169,7 +174,7 @@ const handleSync = async () => {
 
 // 重置同步表单
 const resetSyncForm = () => {
-  syncForm.accountId = '54151573'
+  syncForm.accountId = '66612379'
   syncForm.timeRange = []
 }
 

+ 54 - 7
portal/src/views/auth/index.vue

@@ -102,12 +102,34 @@
       width="600px"
     >
       <el-form :model="newAuthForm" label-width="120px">
-        <el-form-item label="回调地址">
+        <el-form-item label="Client ID" required>
+          <el-input
+            v-model="newAuthForm.clientId"
+            placeholder="请输入应用的 Client ID"
+          />
+        </el-form-item>
+        <el-form-item label="回调地址" required>
           <el-input
             v-model="newAuthForm.redirectUri"
             placeholder="请输入回调地址,如:http://localhost:5173/callback"
           />
         </el-form-item>
+        <el-form-item label="授权范围">
+          <el-input
+            v-model="newAuthForm.scope"
+            placeholder="可选,不传表示授权应用所有权限,如:ads_management"
+          />
+        </el-form-item>
+        <el-form-item label="账号类型">
+          <el-select
+            v-model="newAuthForm.accountType"
+            placeholder="请选择账号类型"
+            style="width: 100%"
+          >
+            <el-option label="QQ号(默认)" value="ACCOUNT_TYPE_QQ" />
+            <el-option label="微信号" value="ACCOUNT_TYPE_WECHAT" />
+          </el-select>
+        </el-form-item>
         <el-form-item label="状态参数">
           <el-input
             v-model="newAuthForm.state"
@@ -120,9 +142,10 @@
           :closable="false"
           style="margin-bottom: 20px"
         >
-          <p>1. 点击"获取授权链接"按钮生成授权URL</p>
-          <p>2. 复制链接并在浏览器中打开,完成腾讯广告平台授权</p>
-          <p>3. 授权成功后,系统会自动保存token信息</p>
+          <p>1. 填写应用的 Client ID 和回调地址(必填)</p>
+          <p>2. 点击"获取授权链接"按钮生成授权URL</p>
+          <p>3. 复制链接并在浏览器中打开,完成腾讯广告平台授权</p>
+          <p>4. 授权成功后,浏览器会跳转到回调地址,系统会自动保存token信息</p>
         </el-alert>
       </el-form>
       <template #footer>
@@ -230,7 +253,10 @@ const authUrlLoading = ref(false)
 
 // 表单数据
 const newAuthForm = ref({
+  clientId: '',
   redirectUri: 'http://localhost:5173/callback',
+  scope: '',
+  accountType: 'ACCOUNT_TYPE_QQ',
   state: 'state_' + Date.now()
 })
 
@@ -270,10 +296,21 @@ const loadExpiringList = async () => {
 
 const showNewAuthDialog = () => {
   newAuthDialogVisible.value = true
-  newAuthForm.value.state = 'state_' + Date.now()
+  // 重置表单,保留默认值
+  newAuthForm.value = {
+    clientId: '',
+    redirectUri: 'http://localhost:5173/callback',
+    scope: '',
+    accountType: 'ACCOUNT_TYPE_QQ',
+    state: 'state_' + Date.now()
+  }
 }
 
 const handleGetAuthUrl = async () => {
+  if (!newAuthForm.value.clientId) {
+    ElMessage.warning('请输入 Client ID')
+    return
+  }
   if (!newAuthForm.value.redirectUri) {
     ElMessage.warning('请输入回调地址')
     return
@@ -281,10 +318,20 @@ const handleGetAuthUrl = async () => {
   
   authUrlLoading.value = true
   try {
-    const res = await getAuthorizationUrl({
+    const params = {
+      clientId: newAuthForm.value.clientId,
       redirectUri: newAuthForm.value.redirectUri,
       state: newAuthForm.value.state
-    })
+    }
+    // 添加可选参数
+    if (newAuthForm.value.scope) {
+      params.scope = newAuthForm.value.scope
+    }
+    if (newAuthForm.value.accountType) {
+      params.accountType = newAuthForm.value.accountType
+    }
+    
+    const res = await getAuthorizationUrl(params)
     authUrl.value = res.data
     newAuthDialogVisible.value = false
     authUrlDialogVisible.value = true

+ 97 - 17
src/main/java/com/moka/gdtauto/client/TencentAdsApiClientFactory.java

@@ -1,7 +1,9 @@
 package com.moka.gdtauto.client;
 
 import com.moka.gdtauto.config.TencentAdsConfig;
+import com.moka.gdtauto.entity.SysUser;
 import com.moka.gdtauto.entity.TencentAdsAuth;
+import com.moka.gdtauto.service.SysUserService;
 import com.moka.gdtauto.service.TencentAdsAuthService;
 import com.tencent.ads.ApiContextConfig;
 import com.tencent.ads.v3.TencentAds;
@@ -25,17 +27,55 @@ public class TencentAdsApiClientFactory {
 
     private final TencentAdsConfig tencentAdsConfig;
     private final TencentAdsAuthService authService;
+    private final SysUserService sysUserService;
 
     /**
-     * 根据账户ID获取TencentAds实例
-     * 自动处理token刷新逻辑
+     * 根据手机号和账户ID获取TencentAds实例
+     * 自动处理token刷新逻辑,并传入user_token
      * 
+     * @param phone 手机号
      * @param accountId 账户ID
      * @return TencentAds实例
      * @throws Exception 当获取token失败时抛出
      */
-    public TencentAds getTencentAds(String accountId) throws Exception {
-        // 获取账户的授权信息
+    public TencentAds getTencentAds(String phone, String accountId) throws Exception {
+        // 获取账户的访问令牌
+        TencentAdsAuth auth = authService.getByAccountId(accountId);
+        if (auth == null) {
+            throw new Exception("未找到账户授权信息:" + accountId);
+        }
+
+        // 检查access_token是否即将过期(5分钟内)
+        LocalDateTime now = LocalDateTime.now();
+        if (auth.getAccessTokenExpiresAt().isBefore(now.plusMinutes(5))) {
+            log.info("账户 {} 的access_token即将过期,开始刷新", accountId);
+            auth = authService.refreshAccessToken(auth);
+        }
+
+        // 获取用户的实名认证令牌
+        SysUser user = sysUserService.getByPhone(phone);
+        String userTokenStr = null;
+        if (user != null && sysUserService.isUserTokenValid(user)) {
+            userTokenStr = user.getUserToken();
+            log.debug("使用手机号 {} 的实名认证令牌", phone);
+        } else {
+            log.warn("手机号 {} 未找到有效的实名认证令牌,部分受限接口将无法调用", phone);
+        }
+
+        // 创建并初始化TencentAds实例
+        return createTencentAds(auth.getAccessToken(), userTokenStr);
+    }
+
+    /**
+     * 根据账户ID获取TencentAds实例(不传入user_token)
+     * 兼容旧方法,不支持受限接口调用
+     * 
+     * @param accountId 账户ID
+     * @return TencentAds实例
+     * @throws Exception 当获取token失败时抛出
+     */
+    public TencentAds getTencentAdsWithoutUserToken(String accountId) throws Exception {
+        // 获取账户的访问令牌
         TencentAdsAuth auth = authService.getByAccountId(accountId);
         if (auth == null) {
             throw new Exception("未找到账户授权信息:" + accountId);
@@ -48,24 +88,46 @@ public class TencentAdsApiClientFactory {
             auth = authService.refreshAccessToken(auth);
         }
 
-        // 创建并初始化TencentAds实例
-        return createTencentAds(auth.getAccessToken());
+        // 创建并初始化TencentAds实例,不传入user_token
+        log.warn("未传入手机号,无法获取user_token,部分受限接口将无法调用");
+        return createTencentAds(auth.getAccessToken(), null);
     }
 
     /**
      * 使用指定的访问令牌创建TencentAds实例
+     * 兼容旧方法,不传入user_token
      * 
      * @param accessToken 访问令牌
      * @return TencentAds实例
      */
     public TencentAds createTencentAds(String accessToken) {
+        return createTencentAds(accessToken, null);
+    }
+
+    /**
+     * 使用指定的访问令牌和实名认证令牌创建TencentAds实例
+     * 
+     * @param accessToken 访问令牌
+     * @param userToken 实名认证令牌(可为null)
+     * @return TencentAds实例
+     */
+    public TencentAds createTencentAds(String accessToken, String userToken) {
         // 按照官方文档初始化
         TencentAds tencentAds = TencentAds.getInstance();
-        tencentAds.init(
-            new ApiContextConfig()
-                .accessToken(accessToken)
-                .isDebug(false)  // 生产环境关闭调试
-        );
+        
+        ApiContextConfig config = new ApiContextConfig()
+            .accessToken(accessToken)
+            .isDebug(false);  // 生产环境关闭调试
+        
+        // 如果有user_token,则传入
+        if (userToken != null && !userToken.isEmpty()) {
+            config.userToken(userToken);
+            log.debug("初始化TencentAds,使用user_token");
+        } else {
+            log.debug("初始化TencentAds,未使用user_token");
+        }
+        
+        tencentAds.init(config);
         
         log.debug("创建TencentAds实例成功");
         return tencentAds;
@@ -92,14 +154,32 @@ public class TencentAdsApiClientFactory {
      * @return TencentAds实例
      */
     public TencentAds createDebugTencentAds(String accessToken) {
+        return createDebugTencentAds(accessToken, null);
+    }
+
+    /**
+     * 创建调试模式TencentAds实例
+     * 
+     * @param accessToken 访问令牌
+     * @param userToken 实名认证令牌(可为null)
+     * @return TencentAds实例
+     */
+    public TencentAds createDebugTencentAds(String accessToken, String userToken) {
         TencentAds tencentAds = TencentAds.getInstance();
-        tencentAds.init(
-            new ApiContextConfig()
-                .accessToken(accessToken)
-                .isDebug(true)  // 开启调试模式
-        );
         
-        log.info("创建调试模式TencentAds实例成功");
+        ApiContextConfig config = new ApiContextConfig()
+            .accessToken(accessToken)
+            .isDebug(true);  // 开启调试模式
+        
+        // 如果有user_token,则传入
+        if (userToken != null && !userToken.isEmpty()) {
+            config.userToken(userToken);
+            log.info("创建调试模式TencentAds实例,使用user_token");
+        } else {
+            log.info("创建调试模式TencentAds实例,未使用user_token");
+        }
+        
+        tencentAds.init(config);
         return tencentAds;
     }
 }

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

@@ -34,7 +34,7 @@ public class TencentAdsAdgroupController {
     @Operation(summary = "同步广告组数据", description = "从腾讯广告平台同步指定账户的广告组数据到数据库")
     @PostMapping("/sync")
     public Map<String, Object> syncAdgroups(
-            @Parameter(description = "账户ID", required = true, example = "54151573")
+            @Parameter(description = "账户ID", required = true, example = "66612379")
             @RequestParam String accountId,
             @Parameter(description = "开始时间戳(秒)", example = "1708520382")
             @RequestParam(required = false) Long startTime,
@@ -61,7 +61,7 @@ public class TencentAdsAdgroupController {
     @Operation(summary = "查询广告组列表", description = "分页查询广告组数据")
     @GetMapping("/list")
     public Map<String, Object> listAdgroups(
-            @Parameter(description = "账户ID(可选)", example = "54151573")
+            @Parameter(description = "账户ID(可选)", example = "66612379")
             @RequestParam(required = false) String accountId,
             @Parameter(description = "页码", example = "1")
             @RequestParam(defaultValue = "1") Integer page,

+ 78 - 3
src/main/java/com/moka/gdtauto/controller/TencentAdsAuthController.java

@@ -1,8 +1,8 @@
 package com.moka.gdtauto.controller;
 
+import com.moka.gdtauto.entity.SysUser;
 import com.moka.gdtauto.entity.TencentAdsAuth;
 import com.moka.gdtauto.service.TencentAdsAuthService;
-import com.tencent.ads.ApiException;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
@@ -24,7 +24,7 @@ import java.util.Map;
 @Tag(name = "OAuth授权管理", description = "腾讯广告账户OAuth2.0授权与Token管理")
 @Slf4j
 @RestController
-@RequestMapping("/api/auth")
+@RequestMapping("/api/gdt/auth")
 @RequiredArgsConstructor
 public class TencentAdsAuthController {
 
@@ -207,7 +207,82 @@ public class TencentAdsAuthController {
     }
 
     /**
-     * 查询即将过期的token(用于监控)
+     * 获取实名认证令牌授权URL
+     * 用于获取user_token,必须在服务商系统或客户工作台完成实名认证后才能使用
+     * 
+     * @param redirectUri 回调地址
+     * @param state 状态参数
+     * @return 实名认证授权URL
+     */
+    @Operation(
+        summary = "获取实名认证令牌URL",
+        description = "生成腾讯广告平台的实名认证链接,引导用户微信扫码完成实名认证,获取user_token用于调用受限接口"
+    )
+    @GetMapping("/user-token-url")
+    public Map<String, Object> getUserTokenUrl(
+            @Parameter(description = "认证完成后的回调地址", required = true, example = "http://your-domain.com/user-token-callback")
+            @RequestParam String redirectUri,
+            @Parameter(description = "自定义参数,用于验证请求有效性", example = "custom_state_123")
+            @RequestParam(required = false, defaultValue = "") String state) {
+        Map<String, Object> result = new HashMap<>();
+        try {
+            String userTokenUrl = authService.getUserTokenUrl(redirectUri, state);
+            result.put("success", true);
+            result.put("data", userTokenUrl);
+            result.put("message", "获取实名认证URL成功");
+            result.put("usage", "请将用户引导到此URL进行微信扫码实名认证");
+            result.put("note", "用户必须在服务商系统或客户工作台完成实名认证+组织认证");
+        } catch (Exception e) {
+            log.error("获取实名认证URL失败", e);
+            result.put("success", false);
+            result.put("message", "获取实名认证URL失败:" + e.getMessage());
+        }
+        return result;
+    }
+    
+    /**
+     * 实名认证回调处理
+     * 处理平台通用组件的回调,通过state参数传递手机号
+     * 
+     * @param userStatus 用户认证状态
+     * @param userToken 实名认证令牌
+     * @param expireTime 令牌过期时间戳
+     * @param state 状态参数(格式:手机号,例如:13800138000)
+     * @return 回调处理结果
+     */
+    @Operation(
+        summary = "实名认证回调处理",
+        description = "接收腾讯广告平台实名认证组件的回调,通过state参数获取手机号,保存user_token到用户表"
+    )
+    @GetMapping("/user-token-callback")
+    public Map<String, Object> userTokenCallback(
+            @Parameter(description = "用户认证状态: 0-未找到账号, 1-未完成实名认证, 2-已完成实名认证", required = true, example = "2")
+            @RequestParam("user_status") Integer userStatus,
+            @Parameter(description = "实名认证令牌", example = "abcdefghijklmn")
+            @RequestParam(value = "user_token", required = false) String userToken,
+            @Parameter(description = "令牌失效时间戳(秒)", example = "1748707200")
+            @RequestParam(value = "expire_time", required = false) Long expireTime,
+            @Parameter(description = "状态参数,用于传递手机号", required = true, example = "13800138000")
+            @RequestParam String state) {
+        Map<String, Object> result = new HashMap<>();
+        try {
+            // state参数就是手机号
+            String phone = state;
+            
+            SysUser user = authService.saveUserToken(phone, userStatus, userToken, expireTime);
+            result.put("success", true);
+            result.put("data", user);
+            result.put("message", "实名认证令牌保存成功");
+        } catch (Exception e) {
+            log.error("保存实名认证令牌失败", e);
+            result.put("success", false);
+            result.put("message", "保存失败:" + e.getMessage());
+        }
+        return result;
+    }
+    
+    /**
+     * 查询即将过期token(用于监控)
      * 
      * @return 即将过期的授权信息列表
      * 

+ 70 - 0
src/main/java/com/moka/gdtauto/entity/SysUser.java

@@ -0,0 +1,70 @@
+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("sys_user")
+public class SysUser {
+
+    /**
+     * 用户ID
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 手机号
+     */
+    private String phone;
+
+    /**
+     * 用户姓名
+     */
+    private String name;
+
+    /**
+     * 实名认证令牌
+     */
+    private String userToken;
+
+    /**
+     * 令牌过期时间
+     */
+    private LocalDateTime userTokenExpiresAt;
+
+    /**
+     * 认证状态:0-未找到账号,1-未完成认证,2-已完成认证
+     */
+    private Integer userStatus;
+
+    /**
+     * 状态:0-禁用,1-启用
+     */
+    private Integer status;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+
+    /**
+     * 逻辑删除:0-未删除,1-已删除
+     */
+    private Integer deleted;
+}

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

@@ -69,6 +69,91 @@ public class TencentAdsAdgroup {
     private Integer isDeleted;
 
     /**
+     * 营销目标
+     */
+    private String marketingGoal;
+
+    /**
+     * 营销资产外层规格
+     */
+    private String marketingAssetOuterSpec;
+
+    /**
+     * 营销目标类型
+     */
+    private String marketingTargetType;
+
+    /**
+     * 营销资产外层ID
+     */
+    private String marketingAssetOuterId;
+
+    /**
+     * 营销资产外层名称
+     */
+    private String marketingAssetOuterName;
+
+    /**
+     * 营销载体类型
+     */
+    private String marketingCarrierType;
+
+    /**
+     * 优化目标
+     */
+    private String optimizationGoal;
+
+    /**
+     * 转化ID
+     */
+    private String conversionId;
+
+    /**
+     * 投放站点集合
+     */
+    private String siteSet;
+
+    /**
+     * 定向设置
+     */
+    private String targeting;
+
+    /**
+     * 出价模式
+     */
+    private String bidMode;
+
+    /**
+     * 出价场景
+     */
+    private String bidScene;
+
+    /**
+     * 开始日期
+     */
+    private String beginDate;
+
+    /**
+     * 结束日期
+     */
+    private String endDate;
+
+    /**
+     * 时间序列
+     */
+    private String timeSeries;
+
+    /**
+     * 是否启用自动衍生创意
+     */
+    private String autoDerivedCreativeEnabled;
+
+    /**
+     * 创意组件
+     */
+    private String creativeComponents;
+
+    /**
      * 记录创建时间
      */
     private LocalDateTime createTime;

+ 1 - 1
src/main/java/com/moka/gdtauto/entity/TencentAdsAuth.java

@@ -54,7 +54,7 @@ public class TencentAdsAuth {
     private String scope;
 
     /**
-     * 状态:0-失效,1-有效
+     * 状态:0-失效,1-有效
      */
     private Integer status;
 

+ 15 - 0
src/main/java/com/moka/gdtauto/mapper/SysUserMapper.java

@@ -0,0 +1,15 @@
+package com.moka.gdtauto.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.moka.gdtauto.entity.SysUser;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 系统用户Mapper接口
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@Mapper
+public interface SysUserMapper extends BaseMapper<SysUser> {
+}

+ 116 - 0
src/main/java/com/moka/gdtauto/service/SysUserService.java

@@ -0,0 +1,116 @@
+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.SysUser;
+import com.moka.gdtauto.mapper.SysUserMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+/**
+ * 系统用户服务
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@Slf4j
+@Service
+public class SysUserService extends ServiceImpl<SysUserMapper, SysUser> {
+
+    /**
+     * 根据手机号查询用户
+     * 
+     * @param phone 手机号
+     * @return 用户信息
+     */
+    public SysUser getByPhone(String phone) {
+        LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(SysUser::getPhone, phone)
+                .eq(SysUser::getDeleted, 0)
+                .last("LIMIT 1");
+        return getOne(wrapper);
+    }
+
+    /**
+     * 创建或获取用户
+     * 如果用户不存在则创建
+     * 
+     * @param phone 手机号
+     * @param name 用户姓名(可选)
+     * @return 用户信息
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public SysUser getOrCreateUser(String phone, String name) {
+        SysUser user = getByPhone(phone);
+        if (user == null) {
+            user = new SysUser();
+            user.setPhone(phone);
+            user.setName(name);
+            user.setStatus(1);
+            save(user);
+            log.info("创建新用户,手机号:{}", phone);
+        }
+        return user;
+    }
+
+    /**
+     * 保存或更新用户令牌
+     * 
+     * @param phone 手机号
+     * @param userToken 实名认证令牌
+     * @param expiresAt 过期时间
+     * @param userStatus 认证状态
+     * @return 用户信息
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public SysUser saveOrUpdateUserToken(String phone, String userToken, 
+                                         LocalDateTime expiresAt, Integer userStatus) {
+        SysUser user = getByPhone(phone);
+        
+        if (user == null) {
+            user = new SysUser();
+            user.setPhone(phone);
+            user.setStatus(1);
+        }
+        
+        user.setUserToken(userToken);
+        user.setUserTokenExpiresAt(expiresAt);
+        user.setUserStatus(userStatus);
+        
+        saveOrUpdate(user);
+        
+        log.info("保存用户令牌,phone={},过期时间={}", phone, expiresAt);
+        
+        return user;
+    }
+
+    /**
+     * 检查用户令牌是否有效
+     * 
+     * @param user 用户信息
+     * @return 是否有效
+     */
+    public boolean isUserTokenValid(SysUser user) {
+        if (user == null || user.getStatus() == 0) {
+            return false;
+        }
+        
+        if (user.getUserStatus() == null || user.getUserStatus() != 2) {
+            return false;
+        }
+        
+        if (user.getUserToken() == null || user.getUserToken().isEmpty()) {
+            return false;
+        }
+        
+        if (user.getUserTokenExpiresAt() == null) {
+            return false;
+        }
+        
+        LocalDateTime now = LocalDateTime.now();
+        return user.getUserTokenExpiresAt().isAfter(now);
+    }
+}

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

@@ -34,7 +34,7 @@ public class TencentAdsAccountService {
      * @throws Exception API异常
      */
     public TencentAds getTencentAdsInstance(String accountId) throws Exception {
-        return clientFactory.getTencentAds(accountId);
+        return clientFactory.getTencentAdsWithoutUserToken(accountId);
     }
 
     /**
@@ -48,25 +48,25 @@ public class TencentAdsAccountService {
      */
     public AdgroupsGetResponseData getAdgroups(String accountId, Long page, Long pageSize) 
             throws TencentAdsResponseException, TencentAdsSDKException, Exception {
-        log.info("开始获取广告组列表accountId={}, page={}, pageSize={}", accountId, page, pageSize);
-        
+        log.info("开始获取广告组列表,accountId={}, page={}, pageSize={}", accountId, page, pageSize);
+            
         // 获取TencentAds实例
-        TencentAds tencentAds = clientFactory.getTencentAds(accountId);
-        
+        TencentAds tencentAds = clientFactory.getTencentAdsWithoutUserToken(accountId);
+            
         // 调用SDK的adgroups().adgroupsGet()方法
         Long accountIdLong = Long.parseLong(accountId);
-        List<FilteringStruct> filtering = null; // 不传过滤条件获取全部
+        List<FilteringStruct> filtering = null; // 不传过滤条件,获取全部
         Boolean isDeleted = false; // 不包含已删除的广告组
         List<String> fields = null; // 返回所有字段
         String paginationMode = null; // 使用默认分页模式
-        String cursor = null; // 游标分页用普通分页不需要
-        
+        String cursor = null; // 游标分页用,普通分页不需要
+            
         AdgroupsGetResponseData response = tencentAds.adgroups()
             .adgroupsGet(accountIdLong, filtering, page, pageSize, isDeleted, fields, paginationMode, cursor);
-        
-        log.info("获取广告组列表成功返回{}条数据", 
+            
+        log.info("获取广告组列表成功,返回{}条数据", 
             response != null && response.getList() != null ? response.getList().size() : 0);
-        
+            
         return response;
     }
 
@@ -102,7 +102,7 @@ public class TencentAdsAccountService {
             accountId, page, pageSize, filtering);
         
         // 获取TencentAds实例
-        TencentAds tencentAds = clientFactory.getTencentAds(accountId);
+        TencentAds tencentAds = clientFactory.getTencentAdsWithoutUserToken(accountId);
         
         // 调用SDK的adgroups().adgroupsGet()方法
         Long accountIdLong = Long.parseLong(accountId);

+ 114 - 8
src/main/java/com/moka/gdtauto/service/TencentAdsAdgroupService.java

@@ -2,12 +2,13 @@ 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.client.TencentAdsApiClientFactory;
 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 com.tencent.ads.exception.TencentAdsResponseException;
+import com.tencent.ads.exception.TencentAdsSDKException;
+import com.tencent.ads.model.v3.*;
+import com.tencent.ads.v3.TencentAds;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -29,6 +30,7 @@ public class TencentAdsAdgroupService extends ServiceImpl<TencentAdsAdgroupMappe
 
     private final TencentAdsAccountService accountService;
     private final TencentAdsAdgroupMapper adgroupMapper;
+    private final TencentAdsApiClientFactory clientFactory;
 
     /**
      * 同步指定账户的广告组数据
@@ -53,7 +55,7 @@ public class TencentAdsAdgroupService extends ServiceImpl<TencentAdsAdgroupMappe
             
             // 构建返回字段
             List<String> fields = buildFields();
-            
+
             // 调用API获取数据
             AdgroupsGetResponseData response = accountService.getAdgroupsWithFiltering(
                 accountId, page, pageSize, filtering, fields);
@@ -117,6 +119,7 @@ public class TencentAdsAdgroupService extends ServiceImpl<TencentAdsAdgroupMappe
      */
     private List<String> buildFields() {
         return List.of(
+            // 保留的旧字段
             "adgroup_id",
             "adgroup_name",
             "configured_status",
@@ -124,7 +127,25 @@ public class TencentAdsAdgroupService extends ServiceImpl<TencentAdsAdgroupMappe
             "created_time",
             "marketing_scene",
             "last_modified_time",
-            "is_deleted"
+            "is_deleted",
+            // 新增字段
+            "marketing_goal",
+            "marketing_asset_outer_spec",
+            "marketing_target_type",
+            "marketing_asset_outer_id",
+            "marketing_asset_outer_name",
+            "marketing_carrier_type",
+            "optimization_goal",
+            "conversion_id",
+            "site_set",
+            "targeting",
+            "bid_mode",
+            "bid_scene",
+            "begin_date",
+            "end_date",
+            "time_series",
+            "auto_derived_creative_enabled",
+            "creative_components"
         );
     }
 
@@ -146,8 +167,62 @@ public class TencentAdsAdgroupService extends ServiceImpl<TencentAdsAdgroupMappe
             entity.setMarketingScene(api.getMarketingScene() != null ? 
                 api.getMarketingScene().toString() : null);
             entity.setLastModifiedTime(api.getLastModifiedTime());
-            // 注意:SDK可能没有isDeleted字段,设置为0
-            entity.setIsDeleted(0);
+            
+            // isDeleted字段处理(SDK中Boolean类型getter可能是isDeleted())
+            try {
+                Boolean isDeleted = (Boolean) api.getClass().getMethod("isDeleted").invoke(api);
+                entity.setIsDeleted(isDeleted != null && isDeleted ? 1 : 0);
+            } catch (Exception e) {
+                entity.setIsDeleted(0);
+            }
+            
+            // 新增字段转换(根据SDK实际字段类型)
+            // 枚举类型转String
+            entity.setMarketingGoal(api.getMarketingGoal() != null ? api.getMarketingGoal().toString() : null);
+            entity.setMarketingAssetOuterSpec(api.getMarketingAssetOuterSpec() != null ? api.getMarketingAssetOuterSpec().toString() : null);
+            entity.setMarketingTargetType(api.getMarketingTargetType() != null ? api.getMarketingTargetType().toString() : null);
+            entity.setMarketingCarrierType(api.getMarketingCarrierType() != null ? api.getMarketingCarrierType().toString() : null);
+            entity.setOptimizationGoal(api.getOptimizationGoal() != null ? api.getOptimizationGoal().toString() : null);
+            entity.setBidMode(api.getBidMode() != null ? api.getBidMode().toString() : null);
+            entity.setBidScene(api.getBidScene() != null ? api.getBidScene().toString() : null);
+            
+            // Long类型转String
+            entity.setConversionId(api.getConversionId() != null ? api.getConversionId().toString() : null);
+            
+            // List<String>转逗号分隔字符串
+            if (api.getSiteSet() != null && !api.getSiteSet().isEmpty()) {
+                entity.setSiteSet(String.join(",", api.getSiteSet()));
+            }
+            
+            // 复杂对象转JSON字符串(ReadTargetingSetting)
+            if (api.getTargeting() != null) {
+                try {
+                    entity.setTargeting(api.getTargeting().toString());
+                } catch (Exception e) {
+                    log.warn("转换targeting失败: {}", e.getMessage());
+                }
+            }
+            
+            // String类型直接赋值
+            entity.setBeginDate(api.getBeginDate());
+            entity.setEndDate(api.getEndDate());
+            entity.setTimeSeries(api.getTimeSeries());
+            
+            // Boolean类型转String(使用反射处理)
+            try {
+                Boolean autoDerived = (Boolean) api.getClass().getMethod("getAutoDerivedCreativeEnabled").invoke(api);
+                entity.setAutoDerivedCreativeEnabled(autoDerived != null ? autoDerived.toString() : null);
+            } catch (Exception e) {
+                // SDK不支持该字段
+            }
+            
+            // creative_components - SDK中未找到此字段,保留为null
+            // marketing_asset_outer_id - SDK中字段为marketingAssetId (Long)
+            entity.setMarketingAssetOuterId(api.getMarketingAssetId() != null ? 
+                api.getMarketingAssetId().toString() : null);
+            
+            // marketing_asset_outer_name - SDK中未找到,使用conversionName代替
+            entity.setMarketingAssetOuterName(api.getConversionName());
             
             result.add(entity);
         }
@@ -205,4 +280,35 @@ public class TencentAdsAdgroupService extends ServiceImpl<TencentAdsAdgroupMappe
         
         return count(wrapper);
     }
+
+    /**
+     * 创建广告
+     * 
+     * @param accountId 广告主账户ID
+     * @param request 创建广告请求参数
+     * @return 创建成功的广告信息
+     * @throws TencentAdsResponseException API响应异常
+     * @throws TencentAdsSDKException SDK异常
+     * @throws Exception 其他异常
+     */
+    public AdgroupsAddResponseData createAdgroup(String accountId, AdgroupsAddRequest request) 
+            throws TencentAdsResponseException, TencentAdsSDKException, Exception {
+        log.info("开始创建广告,accountId={}, adgroupName={}", accountId, request.getAdgroupName());
+        
+        // 获取TencentAds实例
+        TencentAds tencentAds = clientFactory.getTencentAdsWithoutUserToken(accountId);
+        
+        // 设置账户ID
+        request.setAccountId(Long.parseLong(accountId));
+        
+        // 调用SDK创建广告
+        AdgroupsAddResponseData response = tencentAds.adgroups()
+            .adgroupsAdd(request);
+        
+        log.info("创建广告成功,adgroupId={}", 
+            response.getAdgroupId());
+        
+        return response;
+    }
+
 }

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

@@ -3,6 +3,7 @@ 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.config.TencentAdsConfig;
+import com.moka.gdtauto.entity.SysUser;
 import com.moka.gdtauto.entity.TencentAdsAuth;
 import com.moka.gdtauto.mapper.TencentAdsAuthMapper;
 import com.tencent.ads.ApiContextConfig;
@@ -31,6 +32,7 @@ import java.util.List;
 public class TencentAdsAuthService extends ServiceImpl<TencentAdsAuthMapper, TencentAdsAuth> {
 
     private final TencentAdsConfig tencentAdsConfig;
+    private final SysUserService sysUserService;
 
     /**
      * 获取授权URL
@@ -49,6 +51,98 @@ public class TencentAdsAuthService extends ServiceImpl<TencentAdsAuthMapper, Ten
     }
 
     /**
+     * 获取实名认证令牌授权URL
+     * 
+     * @param redirectUri 认证完成后的回调地址
+     * @param state 自定义参数
+     * @return 实名认证授权URL
+     */
+    public String getUserTokenUrl(String redirectUri, String state) {
+        try {
+            String encodedRedirectUri = java.net.URLEncoder.encode(redirectUri, "UTF-8");
+            String encodedState = state != null && !state.isEmpty() 
+                ? java.net.URLEncoder.encode(state, "UTF-8") 
+                : "";
+            
+            String baseUrl = "https://ad.qq.com/account-center/single/user-authorize";
+            if (encodedState.isEmpty()) {
+                return String.format("%s?redirect_uri=%s", baseUrl, encodedRedirectUri);
+            } else {
+                return String.format("%s?redirect_uri=%s&state=%s", 
+                    baseUrl, encodedRedirectUri, encodedState);
+            }
+        } catch (Exception e) {
+            log.error("生成实名认证URL失败", e);
+            throw new RuntimeException("生成实名认证URL失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 保存实名认证令牌
+     * 通过手机号直接保存
+     * 
+     * @param phone 手机号(从state参数解析)
+     * @param userStatus 用户认证状态: 0-未找到账号, 1-未完成实名认证, 2-已完成实名认证
+     * @param userToken 实名认证令牌
+     * @param expireTime 令牌过期时间戳(秒)
+     * @return 用户信息
+     * @throws Exception 处理异常
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public SysUser saveUserToken(String phone, Integer userStatus, 
+                                 String userToken, Long expireTime) throws Exception {
+        // 校验手机号
+        if (phone == null || phone.isEmpty()) {
+            throw new Exception("手机号不能为空");
+        }
+        
+        if (userStatus == null) {
+            throw new Exception("用户认证状态不能为空");
+        }
+        
+        if (userStatus == 0) {
+            throw new Exception("未找到腾讯广告员工账号,请先在服务商系统或客户工作台注册");
+        }
+        
+        if (userStatus == 1) {
+            throw new Exception("账号未完成实名认证,请先在服务商系统或客户工作台完成实名认证+组织认证");
+        }
+        
+        // userStatus == 2 表示已完成实名认证
+        if (userToken == null || userToken.isEmpty()) {
+            throw new Exception("实名认证令牌不能为空");
+        }
+        
+        if (expireTime == null) {
+            throw new Exception("令牌过期时间不能为空");
+        }
+        
+        try {
+            // URL解码user_token
+            String decodedUserToken = java.net.URLDecoder.decode(userToken, "UTF-8");
+            
+            // 转换过期时间
+            LocalDateTime expiresAt = LocalDateTime.ofInstant(
+                java.time.Instant.ofEpochSecond(expireTime),
+                java.time.ZoneId.systemDefault()
+            );
+            
+            // 保存到用户表
+            SysUser user = sysUserService.saveOrUpdateUserToken(
+                phone, decodedUserToken, expiresAt, userStatus
+            );
+            
+            log.info("保存实名认证令牌成功,phone={},过期时间={}", phone, expiresAt);
+            
+            return user;
+            
+        } catch (Exception e) {
+            log.error("保存实名认证令牌失败", e);
+            throw new Exception("保存实名认证令牌失败:" + e.getMessage());
+        }
+    }
+
+    /**
      * 通过授权码获取访问令牌
      * 使用官方SDK推荐方式
      * 

+ 3 - 3
src/main/resources/application-local.yml

@@ -28,10 +28,10 @@ spring:
   # Redis配置
   data:
     redis:
-      host: localhost
+      host: 172.18.71.26
       port: 6379
-      password: 
-      database: 0
+      password: Jch9shshl
+      database: 14
       lettuce:
         pool:
           max-active: 8

+ 54 - 3
src/main/resources/mapper/TencentAdsAdgroupMapper.xml

@@ -13,7 +13,24 @@
             created_time,
             marketing_scene,
             last_modified_time,
-            is_deleted
+            is_deleted,
+            marketing_goal,
+            marketing_asset_outer_spec,
+            marketing_target_type,
+            marketing_asset_outer_id,
+            marketing_asset_outer_name,
+            marketing_carrier_type,
+            optimization_goal,
+            conversion_id,
+            site_set,
+            targeting,
+            bid_mode,
+            bid_scene,
+            begin_date,
+            end_date,
+            time_series,
+            auto_derived_creative_enabled,
+            creative_components
         )
         VALUES
         <foreach collection="list" item="item" separator=",">
@@ -26,7 +43,24 @@
                 #{item.createdTime},
                 #{item.marketingScene},
                 #{item.lastModifiedTime},
-                #{item.isDeleted}
+                #{item.isDeleted},
+                #{item.marketingGoal},
+                #{item.marketingAssetOuterSpec},
+                #{item.marketingTargetType},
+                #{item.marketingAssetOuterId},
+                #{item.marketingAssetOuterName},
+                #{item.marketingCarrierType},
+                #{item.optimizationGoal},
+                #{item.conversionId},
+                #{item.siteSet},
+                #{item.targeting},
+                #{item.bidMode},
+                #{item.bidScene},
+                #{item.beginDate},
+                #{item.endDate},
+                #{item.timeSeries},
+                #{item.autoDerivedCreativeEnabled},
+                #{item.creativeComponents}
             )
         </foreach>
         ON DUPLICATE KEY UPDATE
@@ -37,7 +71,24 @@
             created_time = VALUES(created_time),
             marketing_scene = VALUES(marketing_scene),
             last_modified_time = VALUES(last_modified_time),
-            is_deleted = VALUES(is_deleted)
+            is_deleted = VALUES(is_deleted),
+            marketing_goal = VALUES(marketing_goal),
+            marketing_asset_outer_spec = VALUES(marketing_asset_outer_spec),
+            marketing_target_type = VALUES(marketing_target_type),
+            marketing_asset_outer_id = VALUES(marketing_asset_outer_id),
+            marketing_asset_outer_name = VALUES(marketing_asset_outer_name),
+            marketing_carrier_type = VALUES(marketing_carrier_type),
+            optimization_goal = VALUES(optimization_goal),
+            conversion_id = VALUES(conversion_id),
+            site_set = VALUES(site_set),
+            targeting = VALUES(targeting),
+            bid_mode = VALUES(bid_mode),
+            bid_scene = VALUES(bid_scene),
+            begin_date = VALUES(begin_date),
+            end_date = VALUES(end_date),
+            time_series = VALUES(time_series),
+            auto_derived_creative_enabled = VALUES(auto_derived_creative_enabled),
+            creative_components = VALUES(creative_components)
     </insert>
 
 </mapper>

+ 18 - 0
src/main/resources/sql/create_user_tables.sql

@@ -0,0 +1,18 @@
+-- 创建系统用户表(包含实名认证令牌)
+CREATE TABLE `sys_user` (
+    `id` BIGINT AUTO_INCREMENT COMMENT '用户ID',
+    `phone` VARCHAR(32) NOT NULL COMMENT '手机号',
+    `name` VARCHAR(64) NULL COMMENT '用户姓名',
+    `user_token` VARCHAR(512) NULL COMMENT '实名认证令牌',
+    `user_token_expires_at` DATETIME NULL COMMENT '令牌过期时间',
+    `user_status` TINYINT(1) NULL COMMENT '认证状态:0-未找到账号,1-未完成认证,2-已完成认证',
+    `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-已删除',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uidx_phone` (`phone`)
+) COMMENT='系统用户表';
+
+CREATE INDEX `idx_status` ON `sys_user` (`status`);
+CREATE INDEX `idx_user_token_expires_at` ON `sys_user` (`user_token_expires_at`);

+ 17 - 0
src/main/resources/sql/init.sql

@@ -41,6 +41,23 @@ create table tencent_ads_adgroup
     marketing_scene     varchar(64)                          null comment '营销场景',
     last_modified_time  bigint                               null comment '最后修改时间(时间戳)',
     is_deleted          tinyint(1) default 0                 null comment '是否已删除:0-否,1-是',
+    marketing_goal      varchar(64)                          null comment '营销目标',
+    marketing_asset_outer_spec varchar(64)                   null comment '营销资产外层规格',
+    marketing_target_type varchar(64)                        null comment '营销目标类型',
+    marketing_asset_outer_id varchar(128)                    null comment '营销资产外层ID',
+    marketing_asset_outer_name varchar(256)                  null comment '营销资产外层名称',
+    marketing_carrier_type varchar(64)                       null comment '营销载体类型',
+    optimization_goal   varchar(64)                          null comment '优化目标',
+    conversion_id       varchar(128)                         null comment '转化ID',
+    site_set            text                                 null comment '投放站点集合',
+    targeting           text                                 null comment '定向设置',
+    bid_mode            varchar(64)                          null comment '出价模式',
+    bid_scene           varchar(64)                          null comment '出价场景',
+    begin_date          varchar(32)                          null comment '开始日期',
+    end_date            varchar(32)                          null comment '结束日期',
+    time_series         text                                 null comment '时间序列',
+    auto_derived_creative_enabled varchar(16)                null comment '是否启用自动衍生创意',
+    creative_components text                                 null comment '创意组件',
     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

+ 14 - 0
src/main/resources/sql/upgrade_user_token.sql

@@ -0,0 +1,14 @@
+-- 升级授权表,添加实名认证令牌字段
+-- 执行时间: 2026-02-04
+-- 用途: 支持腾讯广告Marketing API的实名认证令牌功能
+
+-- 添加user_token字段
+ALTER TABLE tencent_ads_auth 
+ADD COLUMN user_token VARCHAR(512) NULL COMMENT '实名认证令牌' AFTER scope;
+
+-- 添加user_token_expires_at字段
+ALTER TABLE tencent_ads_auth 
+ADD COLUMN user_token_expires_at DATETIME NULL COMMENT '实名认证令牌过期时间' AFTER user_token;
+
+-- 验证修改
+DESCRIBE tencent_ads_auth;

+ 6 - 6
src/test/java/com/moka/gdtauto/client/TencentAdsApiClientFactoryTest.java

@@ -57,7 +57,7 @@ class TencentAdsApiClientFactoryTest {
             mockedStatic.when(TencentAds::getInstance).thenReturn(mockTencentAds);
 
             // When
-            TencentAds result = clientFactory.getTencentAds(TEST_ACCOUNT_ID);
+            TencentAds result = clientFactory.getTencentAdsWithoutUserToken(TEST_ACCOUNT_ID);
 
             // Then
             assertNotNull(result);
@@ -84,7 +84,7 @@ class TencentAdsApiClientFactoryTest {
             mockedStatic.when(TencentAds::getInstance).thenReturn(mockTencentAds);
 
             // When
-            TencentAds result = clientFactory.getTencentAds(TEST_ACCOUNT_ID);
+            TencentAds result = clientFactory.getTencentAdsWithoutUserToken(TEST_ACCOUNT_ID);
 
             // Then
             assertNotNull(result);
@@ -102,7 +102,7 @@ class TencentAdsApiClientFactoryTest {
         try {
             // When & Then
             Exception exception = assertThrows(Exception.class, () -> {
-                clientFactory.getTencentAds(TEST_ACCOUNT_ID);
+                clientFactory.getTencentAdsWithoutUserToken(TEST_ACCOUNT_ID);
             });
 
             assertTrue(exception.getMessage().contains("未找到账户授权信息"));
@@ -122,7 +122,7 @@ class TencentAdsApiClientFactoryTest {
 
         // When & Then
         Exception exception = assertThrows(Exception.class, () -> {
-            clientFactory.getTencentAds(TEST_ACCOUNT_ID);
+            clientFactory.getTencentAdsWithoutUserToken(TEST_ACCOUNT_ID);
         });
 
         assertTrue(exception.getMessage().contains("Refresh token failed"));
@@ -196,7 +196,7 @@ class TencentAdsApiClientFactoryTest {
             mockedStatic.when(TencentAds::getInstance).thenReturn(mockTencentAds);
 
             // When
-            TencentAds result = clientFactory.getTencentAds(TEST_ACCOUNT_ID);
+            TencentAds result = clientFactory.getTencentAdsWithoutUserToken(TEST_ACCOUNT_ID);
 
             // Then
             assertNotNull(result);
@@ -222,7 +222,7 @@ class TencentAdsApiClientFactoryTest {
             mockedStatic.when(TencentAds::getInstance).thenReturn(mockTencentAds);
 
             // When
-            TencentAds result = clientFactory.getTencentAds(TEST_ACCOUNT_ID);
+            TencentAds result = clientFactory.getTencentAdsWithoutUserToken(TEST_ACCOUNT_ID);
 
             // Then
             assertNotNull(result);

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

@@ -24,7 +24,7 @@ class AdgroupsApiManualTest {
     @Autowired
     private TencentAdsAccountService accountService;
 
-    private static final String TEST_ACCOUNT_ID = "54151573";
+    private static final String TEST_ACCOUNT_ID = "66612379";
 
     @Test
     @DisplayName("手动测试-获取广告组列表第1页")

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

@@ -41,7 +41,7 @@ class TencentAdsAccountIntegrationTest {
     @Autowired
     private TencentAdsAuthMapper authMapper;
 
-    private static final String TEST_ACCOUNT_ID = "54151573";
+    private static final String TEST_ACCOUNT_ID = "66612379";
 
     @BeforeEach
     void setUp() {

+ 195 - 0
src/test/java/com/moka/gdtauto/service/TencentAdsAdgroupCreateTest.java

@@ -0,0 +1,195 @@
+package com.moka.gdtauto.service;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.moka.gdtauto.GdtAutoApplication;
+import com.tencent.ads.model.v3.*;
+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 java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 腾讯广告创建测试
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@SpringBootTest(classes = GdtAutoApplication.class)
+@ActiveProfiles("local")
+class TencentAdsAdgroupCreateTest {
+
+    @Autowired
+    private TencentAdsAdgroupService adgroupService;
+
+    private static final String TEST_ACCOUNT_ID = "66612379";
+
+    @Test
+    @DisplayName("测试创建广告-使用提供的测试数据")
+    void testCreateAdgroupWithTestData() {
+        System.out.println("\n========================================");
+        System.out.println("开始创建广告");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 构建测试数据(您提供的JSON数据)
+            AdgroupsAddRequest request = buildTestRequest();
+            
+            // 输出请求参数
+            System.out.println("========== 请求参数 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(request));
+            System.out.println("========================================\n");
+
+            // 调用创建广告接口
+            System.out.println("正在调用腾讯广告创建API...");
+            AdgroupsAddResponseData response = adgroupService.createAdgroup(TEST_ACCOUNT_ID, request);
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            Gson gson2 = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson2.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功创建广告");
+                System.out.println("📊 广告ID: " + response.getAdgroupId());
+                System.out.println("📊 广告名称: " + request.getAdgroupName());
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    /**
+     * 构建测试请求对象
+     */
+    private AdgroupsAddRequest buildTestRequest() {
+        AdgroupsAddRequest request = new AdgroupsAddRequest();
+
+        // 基础字段
+        request.setAdgroupName("测试ROI-0204" + System.currentTimeMillis());
+        request.setMarketingGoal(MarketingGoal.USER_GROWTH);
+        request.setMarketingCarrierType(MarketingCarrierType.JUMP_PAGE);
+        
+        // 投放时间
+        request.setBeginDate("2026-02-04");
+        request.setEndDate("2026-02-05");
+        
+        // 优化目标
+        request.setOptimizationGoal(OptimizationGoal.APP_REGISTER);
+        
+        // 时间序列(全部1表示24小时投放,共7天*48个半小时=336位)
+        request.setTimeSeries("111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111");
+        
+        // 投放站点
+        List<String> siteSet = new ArrayList<>();
+        siteSet.add("SITE_SET_SEARCH_SCENE");
+        siteSet.add("SITE_SET_MOBILE_UNION");
+        siteSet.add("SITE_SET_WECHAT");
+        siteSet.add("SITE_SET_KANDIAN");
+        siteSet.add("SITE_SET_QQ_MUSIC_GAME");
+        siteSet.add("SITE_SET_TENCENT_NEWS");
+        siteSet.add("SITE_SET_TENCENT_VIDEO");
+        request.setSiteSet(siteSet);
+        
+        // 出价金额(单位:分)
+        request.setBidAmount(100L);
+        
+        // 日预算(单位:分)
+        request.setDailyBudget(1000L);
+        
+        // 转化ID
+        request.setConversionId(74719216L);
+        
+        // 出价模式
+        request.setBidMode(BidMode.OCPM);
+        
+        // 自动衍生创意
+        request.setAutoDerivedCreativeEnabled(false);
+        
+        // 出价场景
+        request.setBidScene(BidScene.UNKNOWN);
+        
+        // 定向设置
+        WriteTargetingSetting targeting = buildTargetingSetting();
+        request.setTargeting(targeting);
+
+        return request;
+    }
+
+    /**
+     * 构建定向设置
+     */
+    private WriteTargetingSetting buildTargetingSetting() {
+        WriteTargetingSetting targeting = new WriteTargetingSetting();
+
+        // 地域定向
+        GeoLocations geoLocation = new GeoLocations();
+        List<String> locationTypes = new ArrayList<>();
+        locationTypes.add("LIVE_IN"); // 居住地
+        geoLocation.setLocationTypes(locationTypes);
+        List<Long> regions = new ArrayList<>();
+        regions.add(1156L); // 地域ID:1156(中国)
+        geoLocation.setRegions(regions);
+        targeting.setGeoLocation(geoLocation);
+
+        // 性别定向
+        List<String> gender = new ArrayList<>();
+        gender.add("MALE"); // 男性
+        targeting.setGender(gender);
+
+        // 年龄定向
+        List<AgeStruct> age = new ArrayList<>();
+        AgeStruct ageRange = new AgeStruct();
+        ageRange.setMin(35L);
+        ageRange.setMax(66L);
+        age.add(ageRange);
+        targeting.setAge(age);
+
+        // 排除转化用户
+        ExcludedConvertedAudience excludedConverted = new ExcludedConvertedAudience();
+        excludedConverted.setExcludedDimension(ExcludedDimension.EXCLUDED_DIMENSION_GROUP);
+        
+        List<String> conversionBehaviorList = new ArrayList<>();
+        conversionBehaviorList.add("OPTIMIZATIONGOAL_BRAND_CONVERSION");
+        conversionBehaviorList.add("OPTIMIZATIONGOAL_PAGE_SCAN_CODE");
+        excludedConverted.setConversionBehaviorList(conversionBehaviorList);
+        
+        excludedConverted.setExcludedDay(ExcludedDay.valueOf("EXCLUDED_DAY_ONE_MONTH"));
+        targeting.setExcludedConvertedAudience(excludedConverted);
+
+        // 排除自定义人群
+        List<Long> excludedCustomAudience = new ArrayList<>();
+        excludedCustomAudience.add(43712084L);
+        excludedCustomAudience.add(44107982L);
+        excludedCustomAudience.add(44106830L);
+        excludedCustomAudience.add(44241932L);
+        targeting.setExcludedCustomAudience(excludedCustomAudience);
+
+        return targeting;
+    }
+
+    @Test
+    @DisplayName("打印测试数据JSON")
+    void printTestDataJson() {
+        AdgroupsAddRequest request = buildTestRequest();
+        Gson gson = new GsonBuilder().setPrettyPrinting().create();
+        String json = gson.toJson(request);
+        System.out.println("\n========== 测试数据JSON ==========");
+        System.out.println(json);
+        System.out.println("========================================\n");
+    }
+}