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

feat: GDT接口对接与功能界面

pudongliang 5 месяцев назад
Родитель
Сommit
0d1e5eec07
76 измененных файлов с 8851 добавлено и 420 удалено
  1. 11 2
      TODO.md
  2. 186 0
      docs/WATERMARK_USAGE.md
  3. 24 0
      pom.xml
  4. 822 1
      portal/package-lock.json
  5. 10 6
      portal/package.json
  6. 6 0
      portal/postcss.config.js
  7. 2 7
      portal/src/App.vue
  8. 49 0
      portal/src/api/account.js
  9. 10 13
      portal/src/api/auth.js
  10. 39 0
      portal/src/api/conversion.js
  11. 40 0
      portal/src/api/drama.js
  12. 46 0
      portal/src/api/image.js
  13. 53 0
      portal/src/api/video.js
  14. 50 81
      portal/src/layout/index.vue
  15. 1 0
      portal/src/main.js
  16. 30 0
      portal/src/router/index.js
  17. 4 71
      portal/src/style.css
  18. 376 0
      portal/src/views/account/index.vue
  19. 24 48
      portal/src/views/adgroup/index.vue
  20. 58 71
      portal/src/views/auth/index.vue
  21. 321 0
      portal/src/views/conversion/index.vue
  22. 154 18
      portal/src/views/dashboard/index.vue
  23. 312 0
      portal/src/views/drama/index.vue
  24. 466 0
      portal/src/views/image/index.vue
  25. 448 0
      portal/src/views/video/index.vue
  26. 38 0
      portal/tailwind.config.js
  27. 2 1
      portal/vite.config.js
  28. 198 0
      src/main/java/com/moka/gdtauto/common/Result.java
  29. 132 0
      src/main/java/com/moka/gdtauto/controller/TencentAccountController.java
  30. 10 21
      src/main/java/com/moka/gdtauto/controller/TencentAdsAdgroupController.java
  31. 35 79
      src/main/java/com/moka/gdtauto/controller/TencentAdsAuthController.java
  32. 134 0
      src/main/java/com/moka/gdtauto/controller/TencentAssetDramaController.java
  33. 126 0
      src/main/java/com/moka/gdtauto/controller/TencentConversionController.java
  34. 157 0
      src/main/java/com/moka/gdtauto/controller/TencentImagesController.java
  35. 163 0
      src/main/java/com/moka/gdtauto/controller/TencentVideoController.java
  36. 85 0
      src/main/java/com/moka/gdtauto/entity/TencentAccount.java
  37. 65 0
      src/main/java/com/moka/gdtauto/entity/TencentAssetDrama.java
  38. 45 0
      src/main/java/com/moka/gdtauto/entity/TencentConversion.java
  39. 110 0
      src/main/java/com/moka/gdtauto/entity/TencentImages.java
  40. 115 0
      src/main/java/com/moka/gdtauto/entity/TencentVideo.java
  41. 27 0
      src/main/java/com/moka/gdtauto/mapper/TencentAccountMapper.java
  42. 27 0
      src/main/java/com/moka/gdtauto/mapper/TencentAssetDramaMapper.java
  43. 15 0
      src/main/java/com/moka/gdtauto/mapper/TencentConversionMapper.java
  44. 15 0
      src/main/java/com/moka/gdtauto/mapper/TencentImagesMapper.java
  45. 15 0
      src/main/java/com/moka/gdtauto/mapper/TencentVideoMapper.java
  46. 31 0
      src/main/java/com/moka/gdtauto/service/TencentAccountService.java
  47. 175 0
      src/main/java/com/moka/gdtauto/service/TencentAssetDramaService.java
  48. 24 0
      src/main/java/com/moka/gdtauto/service/TencentConversionService.java
  49. 27 0
      src/main/java/com/moka/gdtauto/service/TencentImagesService.java
  50. 29 0
      src/main/java/com/moka/gdtauto/service/TencentVideoService.java
  51. 166 0
      src/main/java/com/moka/gdtauto/service/impl/TencentAccountServiceImpl.java
  52. 146 0
      src/main/java/com/moka/gdtauto/service/impl/TencentConversionServiceImpl.java
  53. 201 0
      src/main/java/com/moka/gdtauto/service/impl/TencentImagesServiceImpl.java
  54. 224 0
      src/main/java/com/moka/gdtauto/service/impl/TencentVideoServiceImpl.java
  55. 162 0
      src/main/java/com/moka/gdtauto/util/ImageWatermarkUtil.java
  56. 240 0
      src/main/java/com/moka/gdtauto/util/VideoWatermarkUtil.java
  57. 46 0
      src/main/resources/mapper/TencentAccountMapper.xml
  58. 36 0
      src/main/resources/mapper/TencentAssetDramaMapper.xml
  59. 26 0
      src/main/resources/mapper/TencentImagesMapper.xml
  60. 20 0
      src/main/resources/sql/create_tencent_account_table.sql
  61. 14 0
      src/main/resources/sql/create_tencent_asset_drama_table.sql
  62. 10 0
      src/main/resources/sql/create_tencent_conversion_table.sql
  63. 26 0
      src/main/resources/sql/create_tencent_images_table.sql
  64. 28 0
      src/main/resources/sql/create_tencent_video_table.sql
  65. 201 1
      src/test/java/com/moka/gdtauto/service/TencentAdsAdgroupCreateTest.java
  66. 428 0
      src/test/java/com/moka/gdtauto/service/TencentAdsAdgroupROICreateTest.java
  67. 208 0
      src/test/java/com/moka/gdtauto/service/TencentAssetDramaTest.java
  68. 133 0
      src/test/java/com/moka/gdtauto/service/TencentConversionGetTest.java
  69. 315 0
      src/test/java/com/moka/gdtauto/service/TencentImagesAddTest.java
  70. 291 0
      src/test/java/com/moka/gdtauto/service/TencentVideoAddTest.java
  71. 99 0
      src/test/java/com/moka/gdtauto/util/ImageWatermarkSimpleTest.java
  72. 165 0
      src/test/java/com/moka/gdtauto/util/ImageWatermarkUtilTest.java
  73. 128 0
      src/test/java/com/moka/gdtauto/util/VideoWatermarkSimpleTest.java
  74. 196 0
      src/test/java/com/moka/gdtauto/util/VideoWatermarkUtilTest.java
  75. BIN
      test.jpeg
  76. BIN
      test.mp4

+ 11 - 2
TODO.md

@@ -10,7 +10,16 @@
 - 获取token
 - 生成定时任务维护token
 
-66628192
+参考例子
 福建茂同刘文化传媒有限公司南 66612379 线索企微
+福建茂同刘文化传媒有限公司南 76057781 roi 
 
-福建茂同刘文化传媒有限公司南 76057781 roi 
+测试主体与账号
+苏州凤武传媒有限公司上海第一分公司 69781948
+
+
+// https://developers.e.qq.com/v3.0/docs/api/marketing_target_assets/get
+短剧商品
+ab05e0b9cbcd5e5df224f493835f8df9
+69781948
+MARKETING_TARGET_TYPE_SHORT_DRAMA

+ 186 - 0
docs/WATERMARK_USAGE.md

@@ -0,0 +1,186 @@
+# 图片/视频水印功能使用文档
+
+## 功能概述
+
+本项目提供了在图片和视频左上角添加剧名水印的功能,支持:
+
+- ✅ **图片水印**:支持 JPG、PNG、GIF、BMP 等常见格式
+- ✅ **视频水印**:支持 MP4 等常见视频格式
+- ✅ **自定义样式**:可调整字体大小、位置、颜色
+- ✅ **图片压缩**:支持添加水印同时压缩图片
+- ✅ **视频帧提取**:可提取视频第一帧作为预览图
+
+## 依赖库
+
+### 图片处理
+- **Thumbnailator 0.4.20**:轻量级图片处理库,支持压缩和缩放
+- **Java AWT**:内置图形库,用于绘制文字水印
+
+### 视频处理
+- **JavaCV 1.5.10**:Java 视频处理库
+- **FFmpeg 6.1.1**:强大的音视频处理工具
+
+## 使用方法
+
+### 1. 图片水印
+
+#### 基本使用
+
+```java
+import com.moka.gdtauto.util.ImageWatermarkUtil;
+
+// 基本用法:在左上角(50, 50)位置添加水印,字体大小40
+ImageWatermarkUtil.addWatermark(
+    "input.jpg",           // 输入图片路径
+    "output.jpg",          // 输出图片路径
+    "《绝世龙王》"          // 水印文字
+);
+```
+
+#### 自定义位置和字体
+
+```java
+// 自定义字体大小和位置
+ImageWatermarkUtil.addWatermark(
+    "input.jpg",           // 输入图片路径
+    "output.jpg",          // 输出图片路径
+    "《绝世龙王》",         // 水印文字
+    60,                    // 字体大小
+    100,                   // X坐标(从左边距)
+    100                    // Y坐标(从顶部距)
+);
+```
+
+#### 带压缩功能
+
+```java
+// 添加水印并压缩图片到80%大小
+ImageWatermarkUtil.addWatermarkWithCompression(
+    "input.jpg",           // 输入图片路径
+    "output.jpg",          // 输出图片路径
+    "《绝世龙王》",         // 水印文字
+    0.8                    // 缩放比例(0.8 = 80%)
+);
+```
+
+### 2. 视频水印
+
+#### 基本使用
+
+```java
+import com.moka.gdtauto.util.VideoWatermarkUtil;
+
+// 基本用法:在左上角(50, 50)位置添加水印,字体大小40
+VideoWatermarkUtil.addWatermark(
+    "input.mp4",           // 输入视频路径
+    "output.mp4",          // 输出视频路径
+    "《绝世龙王》"          // 水印文字
+);
+```
+
+#### 自定义位置和字体
+
+```java
+// 自定义字体大小和位置
+VideoWatermarkUtil.addWatermark(
+    "input.mp4",           // 输入视频路径
+    "output.mp4",          // 输出视频路径
+    "《绝世龙王》",         // 水印文字
+    60,                    // 字体大小
+    100,                   // X坐标(从左边距)
+    100                    // Y坐标(从顶部距)
+);
+```
+
+#### 提取视频第一帧
+
+```java
+// 提取视频第一帧作为预览图
+VideoWatermarkUtil.extractFirstFrame(
+    "video.mp4",           // 视频路径
+    "preview.jpg"          // 输出图片路径
+);
+```
+
+## 水印样式
+
+- **字体**:微软雅黑(Microsoft YaHei),粗体
+- **颜色**:白色文字 + 黑色描边,提供良好的可读性
+- **透明度**:文字透明度 230/255,描边透明度 180/255
+- **抗锯齿**:启用文字抗锯齿,确保清晰显示
+
+## 运行测试
+
+### 图片水印测试
+
+```bash
+# 编译
+javac -cp "$(mvn dependency:build-classpath -q -DincludeScope=compile -Dmdep.outputFile=/dev/stdout):target/classes" \
+  -d target/test-classes \
+  src/test/java/com/moka/gdtauto/util/ImageWatermarkSimpleTest.java
+
+# 运行
+java -cp "target/classes:target/test-classes:$(mvn dependency:build-classpath -q -DincludeScope=compile -Dmdep.outputFile=/dev/stdout)" \
+  com.moka.gdtauto.util.ImageWatermarkSimpleTest
+```
+
+### 视频水印测试
+
+```bash
+# 编译
+javac -cp "$(mvn dependency:build-classpath -q -DincludeScope=compile -Dmdep.outputFile=/dev/stdout):target/classes" \
+  -d target/test-classes \
+  src/test/java/com/moka/gdtauto/util/VideoWatermarkSimpleTest.java
+
+# 运行
+java -cp "target/classes:target/test-classes:$(mvn dependency:build-classpath -q -DincludeScope=compile -Dmdep.outputFile=/dev/stdout)" \
+  com.moka.gdtauto.util.VideoWatermarkSimpleTest
+```
+
+## 测试结果
+
+运行测试后,结果文件会保存在以下目录:
+
+- **图片测试**:`target/test-output/watermark/`
+- **视频测试**:`target/test-output/video-watermark/`
+
+## 注意事项
+
+1. **视频处理较慢**:视频水印需要逐帧处理,处理时间较长,请耐心等待
+2. **内存占用**:处理大文件时可能需要较多内存
+3. **字体支持**:确保系统中有中文字体支持,否则可能显示乱码
+4. **格式兼容性**:
+   - 图片:支持 JPG、PNG、GIF、BMP
+   - 视频:推荐使用 MP4 格式
+
+## 常见问题
+
+### Q: 水印文字显示乱码?
+A: 确保系统安装了中文字体,特别是"微软雅黑"或"宋体"。
+
+### Q: 视频处理失败?
+A: 检查视频编码格式是否支持,推荐使用 H.264 编码的 MP4 格式。
+
+### Q: 如何调整水印透明度?
+A: 在工具类中修改 `Color` 对象的第四个参数(alpha 值),范围 0-255。
+
+### Q: 能否在其他位置添加水印?
+A: 可以通过修改 x、y 参数来调整水印位置,或修改工具类支持更多位置选项。
+
+## 扩展开发
+
+如需扩展功能,可以:
+
+1. 在 [ImageWatermarkUtil.java](file:///Users/pudongliang/moka_workspace/gdt-auto/src/main/java/com/moka/gdtauto/util/ImageWatermarkUtil.java) 中添加图片相关功能
+2. 在 [VideoWatermarkUtil.java](file:///Users/pudongliang/moka_workspace/gdt-auto/src/main/java/com/moka/gdtauto/util/VideoWatermarkUtil.java) 中添加视频相关功能
+3. 参考测试用例了解使用方法
+
+## 性能参考
+
+在测试环境中(macOS,JDK 21):
+
+- **图片处理**:1920x1080 图片约 50-100ms
+- **视频处理**:3秒 720p 视频(10fps)约 1-2秒
+- **帧提取**:单帧提取约 50ms
+
+实际性能取决于硬件配置和文件大小。

+ 24 - 0
pom.xml

@@ -31,6 +31,9 @@
         <shedlock.version>5.10.2</shedlock.version>
         <plumelog.version>3.5.2</plumelog.version>
         <marketing-api.version>1.1.106</marketing-api.version>
+        <thumbnailator.version>0.4.20</thumbnailator.version>
+        <javacv.version>1.5.10</javacv.version>
+        <ffmpeg.version>6.1.1-1.5.10</ffmpeg.version>
     </properties>
 
     <dependencies>
@@ -133,6 +136,27 @@
             <version>4.6.0</version>
         </dependency>
 
+        <!-- Thumbnailator 图片处理库 -->
+        <dependency>
+            <groupId>net.coobird</groupId>
+            <artifactId>thumbnailator</artifactId>
+            <version>${thumbnailator.version}</version>
+        </dependency>
+
+        <!-- JavaCV 视频处理库 -->
+        <dependency>
+            <groupId>org.bytedeco</groupId>
+            <artifactId>javacv-platform</artifactId>
+            <version>${javacv.version}</version>
+        </dependency>
+
+        <!-- FFmpeg for video processing -->
+        <dependency>
+            <groupId>org.bytedeco</groupId>
+            <artifactId>ffmpeg-platform</artifactId>
+            <version>${ffmpeg.version}</version>
+        </dependency>
+
         <!-- Spring Boot Test -->
         <dependency>
             <groupId>org.springframework.boot</groupId>

+ 822 - 1
portal/package-lock.json

@@ -15,11 +15,28 @@
         "vue-router": "^4.3.0"
       },
       "devDependencies": {
+        "@tailwindcss/postcss": "^4.1.18",
         "@vitejs/plugin-vue": "^6.0.0",
+        "autoprefixer": "^10.4.24",
+        "postcss": "^8.5.6",
         "sass": "^1.79.0",
+        "tailwindcss": "^4.1.18",
         "vite": "^7.0.0"
       }
     },
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
     "node_modules/@babel/helper-string-parser": {
       "version": "7.27.1",
       "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -551,12 +568,55 @@
       "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
       "license": "MIT"
     },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
     "node_modules/@jridgewell/sourcemap-codec": {
       "version": "1.5.5",
       "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
       "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
       "license": "MIT"
     },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
     "node_modules/@parcel/watcher": {
       "version": "2.5.6",
       "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz",
@@ -1235,6 +1295,277 @@
         "win32"
       ]
     },
+    "node_modules/@tailwindcss/node": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.1.18.tgz",
+      "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.4",
+        "enhanced-resolve": "^5.18.3",
+        "jiti": "^2.6.1",
+        "lightningcss": "1.30.2",
+        "magic-string": "^0.30.21",
+        "source-map-js": "^1.2.1",
+        "tailwindcss": "4.1.18"
+      }
+    },
+    "node_modules/@tailwindcss/oxide": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
+      "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 10"
+      },
+      "optionalDependencies": {
+        "@tailwindcss/oxide-android-arm64": "4.1.18",
+        "@tailwindcss/oxide-darwin-arm64": "4.1.18",
+        "@tailwindcss/oxide-darwin-x64": "4.1.18",
+        "@tailwindcss/oxide-freebsd-x64": "4.1.18",
+        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
+        "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
+        "@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
+        "@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
+        "@tailwindcss/oxide-linux-x64-musl": "4.1.18",
+        "@tailwindcss/oxide-wasm32-wasi": "4.1.18",
+        "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
+        "@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-android-arm64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
+      "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-arm64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
+      "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-darwin-x64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
+      "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-freebsd-x64": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
+      "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
+      "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
+      "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
+      "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
+      "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
+      "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
+      "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
+      "bundleDependencies": [
+        "@napi-rs/wasm-runtime",
+        "@emnapi/core",
+        "@emnapi/runtime",
+        "@tybys/wasm-util",
+        "@emnapi/wasi-threads",
+        "tslib"
+      ],
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1",
+        "@emnapi/wasi-threads": "^1.1.0",
+        "@napi-rs/wasm-runtime": "^1.1.0",
+        "@tybys/wasm-util": "^0.10.1",
+        "tslib": "^2.4.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
+      "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
+      "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@tailwindcss/postcss": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
+      "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "@tailwindcss/node": "4.1.18",
+        "@tailwindcss/oxide": "4.1.18",
+        "postcss": "^8.4.41",
+        "tailwindcss": "4.1.18"
+      }
+    },
     "node_modules/@types/estree": {
       "version": "1.0.8",
       "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
@@ -1486,6 +1817,43 @@
       "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
       "license": "MIT"
     },
+    "node_modules/autoprefixer": {
+      "version": "10.4.24",
+      "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.24.tgz",
+      "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "browserslist": "^4.28.1",
+        "caniuse-lite": "^1.0.30001766",
+        "fraction.js": "^5.3.4",
+        "picocolors": "^1.1.1",
+        "postcss-value-parser": "^4.2.0"
+      },
+      "bin": {
+        "autoprefixer": "bin/autoprefixer"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
     "node_modules/axios": {
       "version": "1.13.4",
       "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.4.tgz",
@@ -1497,6 +1865,50 @@
         "proxy-from-env": "^1.1.0"
       }
     },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.9.19",
+      "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz",
+      "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.js"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.1",
+      "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz",
+      "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.9.0",
+        "caniuse-lite": "^1.0.30001759",
+        "electron-to-chromium": "^1.5.263",
+        "node-releases": "^2.0.27",
+        "update-browserslist-db": "^1.2.0"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
     "node_modules/call-bind-apply-helpers": {
       "version": "1.0.2",
       "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1510,6 +1922,27 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001769",
+      "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz",
+      "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
     "node_modules/chokidar": {
       "version": "4.0.3",
       "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz",
@@ -1565,7 +1998,6 @@
       "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
       "dev": true,
       "license": "Apache-2.0",
-      "optional": true,
       "engines": {
         "node": ">=8"
       }
@@ -1584,6 +2016,13 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.286",
+      "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
+      "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/element-plus": {
       "version": "2.13.2",
       "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.2.tgz",
@@ -1609,6 +2048,20 @@
         "vue": "^3.3.0"
       }
     },
+    "node_modules/enhanced-resolve": {
+      "version": "5.19.0",
+      "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
+      "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.3.0"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/entities": {
       "version": "7.0.1",
       "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
@@ -1708,6 +2161,16 @@
         "@esbuild/win32-x64": "0.27.2"
       }
     },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/estree-walker": {
       "version": "2.0.2",
       "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -1768,6 +2231,20 @@
         "node": ">= 6"
       }
     },
+    "node_modules/fraction.js": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz",
+      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/rawify"
+      }
+    },
     "node_modules/fsevents": {
       "version": "2.3.3",
       "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -1841,6 +2318,13 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/has-symbols": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1912,6 +2396,277 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/jiti": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz",
+      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz",
+      "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.30.2",
+        "lightningcss-darwin-arm64": "1.30.2",
+        "lightningcss-darwin-x64": "1.30.2",
+        "lightningcss-freebsd-x64": "1.30.2",
+        "lightningcss-linux-arm-gnueabihf": "1.30.2",
+        "lightningcss-linux-arm64-gnu": "1.30.2",
+        "lightningcss-linux-arm64-musl": "1.30.2",
+        "lightningcss-linux-x64-gnu": "1.30.2",
+        "lightningcss-linux-x64-musl": "1.30.2",
+        "lightningcss-win32-arm64-msvc": "1.30.2",
+        "lightningcss-win32-x64-msvc": "1.30.2"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
+      "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
+      "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
+      "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
+      "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
+      "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
+      "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
+      "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
+      "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
+      "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
+      "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.30.2",
+      "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
+      "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
     "node_modules/lodash": {
       "version": "4.17.23",
       "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
@@ -2006,6 +2761,13 @@
       "license": "MIT",
       "optional": true
     },
+    "node_modules/node-releases": {
+      "version": "2.0.27",
+      "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz",
+      "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/normalize-wheel-es": {
       "version": "1.2.0",
       "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
@@ -2059,6 +2821,13 @@
         "node": "^10 || ^12 || >=14"
       }
     },
+    "node_modules/postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/proxy-from-env": {
       "version": "1.1.0",
       "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -2154,6 +2923,27 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/tailwindcss": {
+      "version": "4.1.18",
+      "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.1.18.tgz",
+      "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tapable": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz",
+      "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.15",
       "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2171,6 +2961,37 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
     },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
     "node_modules/vite": {
       "version": "7.3.1",
       "resolved": "https://registry.npmmirror.com/vite/-/vite-7.3.1.tgz",

+ 10 - 6
portal/package.json

@@ -9,15 +9,19 @@
     "preview": "vite preview"
   },
   "dependencies": {
-    "vue": "^3.5.0",
-    "vue-router": "^4.3.0",
-    "element-plus": "^2.8.0",
     "@element-plus/icons-vue": "^2.3.1",
-    "axios": "^1.7.0"
+    "axios": "^1.7.0",
+    "element-plus": "^2.8.0",
+    "vue": "^3.5.0",
+    "vue-router": "^4.3.0"
   },
   "devDependencies": {
+    "@tailwindcss/postcss": "^4.1.18",
     "@vitejs/plugin-vue": "^6.0.0",
-    "vite": "^7.0.0",
-    "sass": "^1.79.0"
+    "autoprefixer": "^10.4.24",
+    "postcss": "^8.5.6",
+    "sass": "^1.79.0",
+    "tailwindcss": "^4.1.18",
+    "vite": "^7.0.0"
   }
 }

+ 6 - 0
portal/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    '@tailwindcss/postcss': {},
+    autoprefixer: {},
+  },
+}

+ 2 - 7
portal/src/App.vue

@@ -6,13 +6,8 @@
 </script>
 
 <style>
-* {
-  margin: 0;
-  padding: 0;
-  box-sizing: border-box;
-}
-
+/* Tailwind CSS v4 已经包含了完整的重置样式,无需额外定义 */
 #app {
-  font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
+  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
 }
 </style>

+ 49 - 0
portal/src/api/account.js

@@ -0,0 +1,49 @@
+import request from '@/utils/request'
+
+/**
+ * 同步组织账户下的广告账户
+ * @param {Number} orgAccountId 组织账户ID
+ */
+export function syncAccounts(orgAccountId) {
+  return request({
+    url: '/api/gdt/account/sync',
+    method: 'post',
+    params: { orgAccountId }
+  })
+}
+
+/**
+ * 查询组织账户下的广告账户列表(分页)
+ * @param {Number} orgAccountId 组织账户ID
+ * @param {Number} page 页码
+ * @param {Number} pageSize 每页数量
+ */
+export function getAccountList(orgAccountId, page = 1, pageSize = 20) {
+  return request({
+    url: '/api/gdt/account/list',
+    method: 'get',
+    params: { orgAccountId, page, pageSize }
+  })
+}
+
+/**
+ * 查询广告账户详情
+ * @param {Number} accountId 广告账户ID
+ */
+export function getAccountDetail(accountId) {
+  return request({
+    url: '/api/gdt/account/detail',
+    method: 'get',
+    params: { accountId }
+  })
+}
+
+/**
+ * 查询所有广告账户
+ */
+export function getAllAccounts() {
+  return request({
+    url: '/api/gdt/account/all',
+    method: 'get'
+  })
+}

+ 10 - 13
portal/src/api/auth.js

@@ -7,25 +7,17 @@ import request from '@/utils/request'
 // 获取授权 URL
 export function getAuthorizationUrl(params) {
   return request({
-    url: '/auth/authorize-url',
+    url: '/api/gdt/auth/authorize-url',
     method: 'get',
     params
   })
 }
 
-// 授权回调处理
-export function handleAuthCallback(params) {
-  return request({
-    url: '/auth/callback',
-    method: 'get',
-    params
-  })
-}
 
 // 手动刷新 Access Token
 export function refreshToken(params) {
   return request({
-    url: '/auth/refresh',
+    url: '/api/gdt/auth/refresh',
     method: 'post',
     params
   })
@@ -34,7 +26,7 @@ export function refreshToken(params) {
 // 查询账户授权信息
 export function getAuthInfo(params) {
   return request({
-    url: '/auth/info',
+    url: '/api/gdt/auth/info',
     method: 'get',
     params
   })
@@ -43,15 +35,20 @@ export function getAuthInfo(params) {
 // 获取所有有效的授权信息
 export function listAuths() {
   return request({
-    url: '/auth/list',
+    url: '/api/gdt/auth/list',
     method: 'get'
   })
 }
 
+// 获取所有有效的授权信息(别名)
+export function getAuthList() {
+  return listAuths()
+}
+
 // 查询即将过期的 token
 export function listExpiringAuths() {
   return request({
-    url: '/auth/expiring',
+    url: '/api/gdt/auth/expiring',
     method: 'get'
   })
 }

+ 39 - 0
portal/src/api/conversion.js

@@ -0,0 +1,39 @@
+import request from '@/utils/request'
+
+/**
+ * 同步转化归因数据
+ * @param {Number} orgAccountId 组织账户ID
+ * @param {Number} accountId 广告账户ID
+ */
+export function syncConversions(orgAccountId, accountId) {
+  return request({
+    url: '/api/gdt/conversion/sync',
+    method: 'post',
+    params: { orgAccountId, accountId }
+  })
+}
+
+/**
+ * 查询转化归因列表(分页)
+ * @param {Number} page 页码
+ * @param {Number} pageSize 每页数量
+ */
+export function getConversionList(page = 1, pageSize = 20) {
+  return request({
+    url: '/api/gdt/conversion/list',
+    method: 'get',
+    params: { page, pageSize }
+  })
+}
+
+/**
+ * 查询转化详情
+ * @param {Number} conversionId 转化ID
+ */
+export function getConversionDetail(conversionId) {
+  return request({
+    url: '/api/gdt/conversion/detail',
+    method: 'get',
+    params: { conversionId }
+  })
+}

+ 40 - 0
portal/src/api/drama.js

@@ -0,0 +1,40 @@
+import request from '@/utils/request'
+
+/**
+ * 同步短剧资产到数据库
+ * @param {Number} orgAccountId 组织账户ID
+ * @param {String} phone 手机号
+ * @param {Number} accountId 推广账号ID
+ */
+export function syncDrama(orgAccountId, phone, accountId) {
+  return request({
+    url: '/api/gdt/asset/drama/sync',
+    method: 'post',
+    params: { orgAccountId, phone, accountId }
+  })
+}
+
+/**
+ * 分页查询短剧资产列表
+ * @param {Number} page 页码
+ * @param {Number} pageSize 每页数量
+ */
+export function getDramaList(page = 1, pageSize = 20) {
+  return request({
+    url: '/api/gdt/asset/drama/list',
+    method: 'get',
+    params: { page, pageSize }
+  })
+}
+
+/**
+ * 查询短剧资产详情
+ * @param {Number} id 短剧资产ID
+ */
+export function getDramaDetail(id) {
+  return request({
+    url: '/api/gdt/asset/drama/detail',
+    method: 'get',
+    params: { id }
+  })
+}

+ 46 - 0
portal/src/api/image.js

@@ -0,0 +1,46 @@
+import request from '../utils/request'
+
+/**
+ * 上传图片到腾讯广告
+ */
+export function uploadImage(formData) {
+  return request({
+    url: '/api/tencent-images/upload',
+    method: 'post',
+    data: formData,
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })
+}
+
+/**
+ * 获取图片列表
+ */
+export function getImageList(params) {
+  return request({
+    url: '/api/tencent-images/list',
+    method: 'get',
+    params
+  })
+}
+
+/**
+ * 获取图片详情
+ */
+export function getImageDetail(id) {
+  return request({
+    url: `/api/tencent-images/${id}`,
+    method: 'get'
+  })
+}
+
+/**
+ * 删除图片
+ */
+export function deleteImage(id) {
+  return request({
+    url: `/api/tencent-images/${id}`,
+    method: 'delete'
+  })
+}

+ 53 - 0
portal/src/api/video.js

@@ -0,0 +1,53 @@
+import request from '@/utils/request'
+
+/**
+ * 上传视频到腾讯广告
+ * @param {FormData} formData 表单数据(包含file, accountId, hasWatermark, dramaId, watermarkDuration, description)
+ */
+export function uploadVideo(formData) {
+  return request({
+    url: '/api/tencent-video/upload',
+    method: 'post',
+    data: formData,
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })
+}
+
+/**
+ * 分页查询视频列表
+ * @param {Number} page 页码
+ * @param {Number} size 每页数量
+ * @param {Number} accountId 账户ID
+ * @param {String} uploadStatus 上传状态
+ */
+export function getVideoList(page = 1, size = 10, accountId = null, uploadStatus = null) {
+  return request({
+    url: '/api/tencent-video/list',
+    method: 'get',
+    params: { page, size, accountId, uploadStatus }
+  })
+}
+
+/**
+ * 查询视频详情
+ * @param {Number} id 视频ID
+ */
+export function getVideoDetail(id) {
+  return request({
+    url: `/api/tencent-video/${id}`,
+    method: 'get'
+  })
+}
+
+/**
+ * 删除视频记录
+ * @param {Number} id 视频ID
+ */
+export function deleteVideo(id) {
+  return request({
+    url: `/api/tencent-video/${id}`,
+    method: 'delete'
+  })
+}

+ 50 - 81
portal/src/layout/index.vue

@@ -1,20 +1,25 @@
 <template>
-  <div class="layout-container">
-    <el-container>
-      <!-- 左侧导航菜单 -->
-      <el-aside width="240px" class="layout-aside">
-        <div class="logo">
-          <h2>腾讯广告自动化</h2>
-        </div>
+  <div class="h-screen bg-gray-100 flex flex-row">
+    <!-- 左侧菜单 -->
+    <aside class="w-64 bg-white border-r border-gray-200 flex-shrink-0 overflow-y-auto flex flex-col">
+      <div class="p-6 border-b border-gray-200">
+        <h2 class="text-xl font-bold text-gray-800 flex items-center gap-2">
+          <span class="bg-blue-500 w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold shadow-sm">广</span>
+          <span>腾讯广告</span>
+        </h2>
+        <p class="text-xs text-gray-500 mt-1">自动化管理系统</p>
+      </div>
+      <nav class="flex-1 py-4">
         <el-menu
           :default-active="activeMenu"
-          class="layout-menu"
+          class="border-0"
           :router="true"
         >
           <el-menu-item
             v-for="route in menuRoutes"
             :key="route.path"
             :index="route.path"
+            class="menu-item"
           >
             <el-icon>
               <component :is="route.meta.icon" />
@@ -22,18 +27,25 @@
             <span>{{ route.meta.title }}</span>
           </el-menu-item>
         </el-menu>
-      </el-aside>
+      </nav>
+    </aside>
 
-      <!-- 右侧内容区域 -->
-      <el-container class="layout-main">
-        <el-header class="layout-header">
-          <div class="header-title">{{ currentTitle }}</div>
-        </el-header>
-        <el-main class="layout-content">
+    <!-- 右侧内容区 -->
+    <main class="flex-1 flex flex-col overflow-hidden">
+      <!-- 顶部导航栏 -->
+      <header class="bg-white border-b border-gray-200 px-6 py-4 flex-shrink-0 shadow-sm h-16 flex items-center">
+        <div class="flex items-center justify-between px-4">
+          <h1 class="text-lg font-semibold text-gray-800">{{ currentTitle }}</h1>
+        </div>
+      </header>
+      
+      <!-- 主内容区 -->
+      <div class="flex-1 overflow-y-auto bg-gray-50 p-6">
+        <div class="max-w-7xl">
           <router-view />
-        </el-main>
-      </el-container>
-    </el-container>
+        </div>
+      </div>
+    </main>
   </div>
 </template>
 
@@ -72,76 +84,33 @@ const currentTitle = computed(() => {
 })
 </script>
 
-<style scoped lang="scss">
-.layout-container {
-  height: 100vh;
-  overflow: hidden;
+<style scoped>
+/* 左侧菜单样式 */
+:deep(.el-menu) {
+  background-color: transparent;
 }
 
-.el-container {
-  height: 100%;
+:deep(.el-menu-item) {
+  color: #4b5563;
+  border-radius: 0.5rem;
+  margin: 0 12px 4px 12px;
+  padding-left: 20px;
+  transition: all 0.2s;
 }
 
-.layout-aside {
-  background-color: #001529;
-  color: #fff;
-  
-  .logo {
-    height: 60px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    background-color: #002140;
-    
-    h2 {
-      margin: 0;
-      font-size: 18px;
-      color: #fff;
-      font-weight: 600;
-    }
-  }
-  
-  .layout-menu {
-    border-right: none;
-    background-color: #001529;
-    
-    :deep(.el-menu-item) {
-      color: rgba(255, 255, 255, 0.65);
-      
-      &:hover {
-        color: #fff;
-        background-color: rgba(255, 255, 255, 0.08);
-      }
-      
-      &.is-active {
-        color: #fff;
-        background-color: #1890ff;
-      }
-    }
-  }
+:deep(.el-menu-item:hover) {
+  background-color: #f3f4f6;
+  color: #1f2937;
 }
 
-.layout-main {
-  flex-direction: column;
-  background-color: #f0f2f5;
-}
-
-.layout-header {
-  background-color: #fff;
-  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
-  display: flex;
-  align-items: center;
-  padding: 0 24px;
-  
-  .header-title {
-    font-size: 18px;
-    font-weight: 500;
-    color: #262626;
-  }
+:deep(.el-menu-item.is-active) {
+  background-color: #eff6ff;
+  color: #2563eb;
+  font-weight: 500;
 }
 
-.layout-content {
-  padding: 24px;
-  overflow-y: auto;
+:deep(.el-menu-item .el-icon) {
+  margin-right: 12px;
+  font-size: 18px;
 }
 </style>

+ 1 - 0
portal/src/main.js

@@ -1,4 +1,5 @@
 import { createApp } from 'vue'
+import './style.css'
 import ElementPlus from 'element-plus'
 import 'element-plus/dist/index.css'
 import * as ElementPlusIconsVue from '@element-plus/icons-vue'

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

@@ -14,12 +14,42 @@ const routes = [
         meta: { title: 'OAuth授权管理', icon: 'Lock' }
       },
       {
+        path: 'account',
+        name: 'Account',
+        component: () => import('@/views/account/index.vue'),
+        meta: { title: '广告账户管理', icon: 'User' }
+      },
+      {
+        path: 'conversion',
+        name: 'Conversion',
+        component: () => import('@/views/conversion/index.vue'),
+        meta: { title: '转化归因管理', icon: 'DataAnalysis' }
+      },
+      {
         path: 'adgroup',
         name: 'Adgroup',
         component: () => import('@/views/adgroup/index.vue'),
         meta: { title: '广告组管理', icon: 'DataBoard' }
       },
       {
+        path: 'drama',
+        name: 'Drama',
+        component: () => import('@/views/drama/index.vue'),
+        meta: { title: '短剧资产管理', icon: 'VideoPlay' }
+      },
+      {
+        path: 'image',
+        name: 'Image',
+        component: () => import('@/views/image/index.vue'),
+        meta: { title: '图片上传', icon: 'Picture' }
+      },
+      {
+        path: 'video',
+        name: 'Video',
+        component: () => import('@/views/video/index.vue'),
+        meta: { title: '视频上传', icon: 'VideoCamera' }
+      },
+      {
         path: 'dashboard',
         name: 'Dashboard',
         component: () => import('@/views/dashboard/index.vue'),

+ 4 - 71
portal/src/style.css

@@ -1,79 +1,12 @@
-:root {
-  font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
-  line-height: 1.5;
-  font-weight: 400;
-
-  color-scheme: light dark;
-  color: rgba(255, 255, 255, 0.87);
-  background-color: #242424;
-
-  font-synthesis: none;
-  text-rendering: optimizeLegibility;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-}
-
-a {
-  font-weight: 500;
-  color: #646cff;
-  text-decoration: inherit;
-}
-a:hover {
-  color: #535bf2;
-}
+@import "tailwindcss";
 
+/* 保留一些合理的默认样式 */
 body {
   margin: 0;
-  display: flex;
-  place-items: center;
-  min-width: 320px;
+  padding: 0;
   min-height: 100vh;
 }
 
-h1 {
-  font-size: 3.2em;
-  line-height: 1.1;
-}
-
-button {
-  border-radius: 8px;
-  border: 1px solid transparent;
-  padding: 0.6em 1.2em;
-  font-size: 1em;
-  font-weight: 500;
-  font-family: inherit;
-  background-color: #1a1a1a;
-  cursor: pointer;
-  transition: border-color 0.25s;
-}
-button:hover {
-  border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
-  outline: 4px auto -webkit-focus-ring-color;
-}
-
-.card {
-  padding: 2em;
-}
-
 #app {
-  max-width: 1280px;
-  margin: 0 auto;
-  padding: 2rem;
-  text-align: center;
-}
-
-@media (prefers-color-scheme: light) {
-  :root {
-    color: #213547;
-    background-color: #ffffff;
-  }
-  a:hover {
-    color: #747bff;
-  }
-  button {
-    background-color: #f9f9f9;
-  }
+  min-height: 100vh;
 }

+ 376 - 0
portal/src/views/account/index.vue

@@ -0,0 +1,376 @@
+<template>
+  <div class="space-y-6">
+    <!-- 主卡片 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <div class="border-b border-gray-200 pb-4 mb-6">
+        <div class="flex items-center justify-between">
+          <h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
+            </svg>
+            广告账户列表
+          </h2>
+          <div class="flex items-center gap-3">
+            <el-select
+              v-model="selectedOrgAccountId"
+              placeholder="选择组织账户"
+              clearable
+              filterable
+              style="width: 250px"
+              @change="handleOrgAccountChange"
+            >
+              <el-option
+                v-for="auth in orgAccounts"
+                :key="auth.accountId"
+                :label="`${auth.accountId} (${auth.accountType || ''})`"
+                :value="auth.accountId"
+              />
+            </el-select>
+            <el-button 
+              type="primary" 
+              @click="handleSync"
+              :loading="syncing"
+              :disabled="!selectedOrgAccountId"
+            >
+              <el-icon><Refresh /></el-icon>
+              同步账户
+            </el-button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 数据统计 -->
+      <div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
+        <div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-blue-600 mb-1">总账户数</p>
+              <p class="text-2xl font-bold text-blue-700">{{ accountList.length }}</p>
+            </div>
+            <div class="p-3 bg-blue-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
+              </svg>
+            </div>
+          </div>
+        </div>
+        <div class="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4 border border-green-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-green-600 mb-1">竞价广告账户</p>
+              <p class="text-2xl font-bold text-green-700">{{ bidAccountCount }}</p>
+            </div>
+            <div class="p-3 bg-green-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
+              </svg>
+            </div>
+          </div>
+        </div>
+        <div class="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-4 border border-purple-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-purple-600 mb-1">微信MP账户</p>
+              <p class="text-2xl font-bold text-purple-700">{{ mpAccountCount }}</p>
+            </div>
+            <div class="p-3 bg-purple-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
+              </svg>
+            </div>
+          </div>
+        </div>
+        <div class="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-lg p-4 border border-yellow-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-yellow-600 mb-1">有备注</p>
+              <p class="text-2xl font-bold text-yellow-700">{{ commentAccountCount }}</p>
+            </div>
+            <div class="p-3 bg-yellow-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+              </svg>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 账户列表表格 -->
+      <el-table
+        :data="accountList"
+        style="width: 100%"
+        v-loading="loading"
+        stripe
+        border
+      >
+        <el-table-column prop="accountId" label="广告账户ID" width="150" />
+        <el-table-column prop="corporationName" label="企业名称" width="200" />
+        <el-table-column label="账户类型" width="120">
+          <template #default="{ row }">
+            <el-tag v-if="row.isBid" type="success" size="small">竞价广告</el-tag>
+            <el-tag v-if="row.isMp" type="info" size="small" style="margin-left: 5px">微信MP</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="comment" label="备注" width="200" show-overflow-tooltip />
+        <el-table-column prop="commentUserId" label="备注用户ID" width="120" />
+        <el-table-column label="更新时间" width="180">
+          <template #default="{ row }">
+            {{ formatDate(row.updateTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              type="primary"
+              size="small"
+              link
+              @click="handleViewDetail(row)"
+            >
+              <el-icon><View /></el-icon>
+              查看详情
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      
+      <!-- 分页组件 -->
+      <div class="mt-6 flex justify-end">
+        <el-pagination
+        v-if="total > 0"
+        v-model:current-page="currentPage"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 50, 100]"
+        :total="total"
+        layout="total, sizes, prev, pager, next, jumper"
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+        style="justify-content: flex-end"
+        />
+      </div>
+    </div>
+
+    <!-- 账户详情对话框 -->
+    <el-dialog
+      v-model="detailDialogVisible"
+      title="广告账户详情"
+      width="600px"
+    >
+      <el-descriptions :column="1" border v-if="currentAccount">
+        <el-descriptions-item label="账户ID">
+          {{ currentAccount.accountId }}
+        </el-descriptions-item>
+        <el-descriptions-item label="组织账户ID">
+          {{ currentAccount.orgAccountId }}
+        </el-descriptions-item>
+        <el-descriptions-item label="企业名称">
+          {{ currentAccount.corporationName }}
+        </el-descriptions-item>
+        <el-descriptions-item label="是否竞价广告账户">
+          <el-tag :type="currentAccount.isBid ? 'success' : 'info'">
+            {{ currentAccount.isBid ? '是' : '否' }}
+          </el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="是否微信MP账户">
+          <el-tag :type="currentAccount.isMp ? 'success' : 'info'">
+            {{ currentAccount.isMp ? '是' : '否' }}
+          </el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="备注用户ID">
+          {{ currentAccount.commentUserId || '无' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="备注内容">
+          {{ currentAccount.comment || '无' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="备注列表">
+          <pre v-if="currentAccount.commentList" style="margin: 0; white-space: pre-wrap; word-break: break-all;">{{ formatJSON(currentAccount.commentList) }}</pre>
+          <span v-else>无</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ formatDate(currentAccount.createTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="更新时间">
+          {{ formatDate(currentAccount.updateTime) }}
+        </el-descriptions-item>
+      </el-descriptions>
+      <template #footer>
+        <el-button @click="detailDialogVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { syncAccounts, getAccountList, getAllAccounts } from '@/api/account'
+import { getAuthList } from '@/api/auth'
+import {
+  Refresh,
+  User,
+  TrendCharts,
+  ChatDotRound,
+  Document,
+  View
+} from '@element-plus/icons-vue'
+
+// 数据
+const loading = ref(false)
+const syncing = ref(false)
+const accountList = ref([])
+const orgAccounts = ref([])
+const selectedOrgAccountId = ref(null)
+const detailDialogVisible = ref(false)
+const currentAccount = ref(null)
+
+// 分页数据
+const currentPage = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+
+// 计算属性
+const bidAccountCount = computed(() => {
+  return accountList.value.filter(account => account.isBid).length
+})
+
+const mpAccountCount = computed(() => {
+  return accountList.value.filter(account => account.isMp).length
+})
+
+const commentAccountCount = computed(() => {
+  return accountList.value.filter(account => account.comment).length
+})
+
+// 方法
+const loadOrgAccounts = async () => {
+  try {
+    const res = await getAuthList()
+    console.log('组织账户列表:', res)
+    if (res.success) {
+      orgAccounts.value = res.data || []
+    }
+  } catch (error) {
+    console.error('加载组织账户失败:', error)
+    ElMessage.error('加载组织账户失败')
+  }
+}
+
+const loadAllAccounts = async () => {
+  loading.value = true
+  try {
+    const res = await getAllAccounts()
+    if (res.success) {
+      accountList.value = res.data || []
+      total.value = res.data?.length || 0
+    } else {
+      ElMessage.error(res.message || '加载账户列表失败')
+    }
+  } catch (error) {
+    console.error('加载账户列表失败:', error)
+    ElMessage.error('加载账户列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleOrgAccountChange = async () => {
+  currentPage.value = 1 // 重置页码
+  await loadAccountList()
+}
+
+const loadAccountList = async () => {
+  
+  loading.value = true
+  try {
+    const res = await getAccountList(selectedOrgAccountId.value, currentPage.value, pageSize.value)
+    if (res.success) {
+      accountList.value = res.data || []
+      total.value = res.total || 0
+    } else {
+      ElMessage.error(res.message || '加载账户列表失败')
+    }
+  } catch (error) {
+    console.error('加载账户列表失败:', error)
+    ElMessage.error('加载账户列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+// 分页方法
+const handleSizeChange = async (newSize) => {
+  pageSize.value = newSize
+  currentPage.value = 1
+  await loadAccountList()
+}
+
+const handleCurrentChange = async (newPage) => {
+  currentPage.value = newPage
+  await loadAccountList()
+}
+
+const handleSync = async () => {
+  if (!selectedOrgAccountId.value) {
+    ElMessage.warning('请先选择组织账户')
+    return
+  }
+
+  try {
+    await ElMessageBox.confirm(
+      '确定要同步该组织账户下的广告账户信息吗?',
+      '提示',
+      {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }
+    )
+
+    syncing.value = true
+    const res = await syncAccounts(selectedOrgAccountId.value)
+    
+    if (res.data.success) {
+      ElMessage.success(res.data.message || '同步成功')
+      // 重新加载账户列表
+      currentPage.value = 1 // 重置到第一页
+      await loadAccountList()
+    } else {
+      ElMessage.error(res.data.message || '同步失败')
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('同步账户失败:', error)
+      ElMessage.error('同步账户失败')
+    }
+  } finally {
+    syncing.value = false
+  }
+}
+
+const handleViewDetail = (account) => {
+  currentAccount.value = account
+  detailDialogVisible.value = true
+}
+
+const formatDate = (dateStr) => {
+  if (!dateStr) return '-'
+  return new Date(dateStr).toLocaleString('zh-CN')
+}
+
+const formatJSON = (jsonStr) => {
+  if (!jsonStr) return ''
+  try {
+    return JSON.stringify(JSON.parse(jsonStr), null, 2)
+  } catch {
+    return jsonStr
+  }
+}
+
+// 生命周期
+onMounted(() => {
+  loadOrgAccounts()
+})
+</script>
+
+<style scoped>
+/* 使用Tailwind CSS类替代自定义样式 */
+</style>

+ 24 - 48
portal/src/views/adgroup/index.vue

@@ -1,11 +1,15 @@
 <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="space-y-6">
+    <!-- 主卡片 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <div class="border-b border-gray-200 pb-4 mb-6">
+        <h1 class="text-2xl font-bold text-gray-800 flex items-center gap-3">
+          <svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
+          </svg>
+          广告组数据同步与查询
+        </h1>
+      </div>
 
       <!-- 同步功能区 -->
       <div class="sync-section">
@@ -97,18 +101,19 @@
         </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 class="mt-6 flex justify-end">
+          <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"
+          />
+        </div>
       </div>
-    </el-card>
+    </div>
   </div>
 </template>
 
@@ -245,34 +250,5 @@ onMounted(() => {
 </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;
-}
+/* 使用Tailwind CSS类替代自定义样式 */
 </style>

+ 58 - 71
portal/src/views/auth/index.vue

@@ -1,48 +1,72 @@
 <template>
-  <div class="auth-container">
-    <el-card class="box-card">
-      <template #header>
-        <div class="card-header">
-          <span>OAuth 授权列表</span>
+  <div class="space-y-6">
+    <!-- 主卡片 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <div class="border-b border-gray-200 pb-4 mb-6">
+        <div class="flex items-center justify-between">
+          <h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
+            </svg>
+            OAuth 授权列表
+          </h2>
           <el-button type="primary" @click="showNewAuthDialog">
             <el-icon><Plus /></el-icon>
             新增授权
           </el-button>
         </div>
-      </template>
+      </div>
 
       <!-- 数据统计 -->
-      <div class="stats-row">
-        <el-row :gutter="20">
-          <el-col :span="8">
-            <el-statistic title="总授权数" :value="authList.length">
-              <template #prefix>
-                <el-icon><User /></el-icon>
-              </template>
-            </el-statistic>
-          </el-col>
-          <el-col :span="8">
-            <el-statistic title="有效授权" :value="validCount" class="success">
-              <template #prefix>
-                <el-icon><CircleCheck /></el-icon>
-              </template>
-            </el-statistic>
-          </el-col>
-          <el-col :span="8">
-            <el-statistic title="即将过期" :value="expiringCount" class="warning">
-              <template #prefix>
-                <el-icon><Warning /></el-icon>
-              </template>
-            </el-statistic>
-          </el-col>
-        </el-row>
+      <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
+        <div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-blue-600 mb-1">总授权数</p>
+              <p class="text-2xl font-bold text-blue-700">{{ authList.length }}</p>
+            </div>
+            <div class="p-3 bg-blue-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
+              </svg>
+            </div>
+          </div>
+        </div>
+        <div class="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4 border border-green-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-green-600 mb-1">有效授权</p>
+              <p class="text-2xl font-bold text-green-700">{{ validCount }}</p>
+            </div>
+            <div class="p-3 bg-green-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
+              </svg>
+            </div>
+          </div>
+        </div>
+        <div class="bg-gradient-to-br from-yellow-50 to-yellow-100 rounded-lg p-4 border border-yellow-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-yellow-600 mb-1">即将过期</p>
+              <p class="text-2xl font-bold text-yellow-700">{{ expiringCount }}</p>
+            </div>
+            <div class="p-3 bg-yellow-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
+              </svg>
+            </div>
+          </div>
+        </div>
       </div>
 
       <!-- 授权列表表格 -->
       <el-table
         :data="authList"
-        style="width: 100%; margin-top: 20px"
+        style="width: 100%"
         v-loading="loading"
+        border
+        stripe
       >
         <el-table-column prop="accountId" label="账户ID" width="150" />
         <el-table-column label="Access Token" width="200">
@@ -93,7 +117,7 @@
           </template>
         </el-table-column>
       </el-table>
-    </el-card>
+    </div>
 
     <!-- 新增授权对话框 -->
     <el-dialog
@@ -395,43 +419,6 @@ onMounted(() => {
 })
 </script>
 
-<style scoped lang="scss">
-.auth-container {
-  .box-card {
-    .card-header {
-      display: flex;
-      justify-content: space-between;
-      align-items: center;
-    }
-  }
-
-  .stats-row {
-    margin-bottom: 20px;
-    padding: 20px;
-    background-color: #f5f7fa;
-    border-radius: 4px;
-
-    :deep(.el-statistic) {
-      text-align: center;
-
-      .el-statistic__head {
-        color: #606266;
-        font-size: 14px;
-      }
-
-      .el-statistic__content {
-        font-size: 24px;
-        font-weight: 600;
-      }
-
-      &.success .el-statistic__content {
-        color: #67c23a;
-      }
-
-      &.warning .el-statistic__content {
-        color: #e6a23c;
-      }
-    }
-  }
-}
+<style scoped>
+/* 使用Tailwind CSS类替代自定义样式 */
 </style>

+ 321 - 0
portal/src/views/conversion/index.vue

@@ -0,0 +1,321 @@
+<template>
+  <div class="space-y-6">
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <div class="border-b border-gray-200 pb-4 mb-6">
+        <div class="flex items-center justify-between">
+          <h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
+            </svg>
+            转化归因列表
+          </h2>
+          <div class="flex items-center gap-3">
+            <el-input
+              v-model="syncOrgAccountId"
+              placeholder="请输入组织账户ID"
+              clearable
+              style="width: 200px"
+            />
+            <el-input
+              v-model="syncAccountId"
+              placeholder="请输入广告账户ID"
+              clearable
+              style="width: 200px"
+              @keyup.enter="handleSync"
+            />
+            <el-button 
+              type="primary" 
+              @click="handleSync"
+              :loading="syncing"
+              :disabled="!syncOrgAccountId || !syncAccountId"
+            >
+              <el-icon><Refresh /></el-icon>
+              同步转化
+            </el-button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 数据统计 -->
+      <div class="stats-row">
+        <el-row :gutter="20">
+          <el-col :span="8">
+            <el-statistic title="转化总数" :value="pagination.total">
+              <template #prefix>
+                <el-icon><DataAnalysis /></el-icon>
+              </template>
+            </el-statistic>
+          </el-col>
+          <el-col :span="8">
+            <el-statistic title="当前页数量" :value="conversionList.length" class="success">
+              <template #prefix>
+                <el-icon><Files /></el-icon>
+              </template>
+            </el-statistic>
+          </el-col>
+          <el-col :span="8">
+            <el-statistic title="总页数" :value="totalPages" class="info">
+              <template #prefix>
+                <el-icon><Document /></el-icon>
+              </template>
+            </el-statistic>
+          </el-col>
+        </el-row>
+      </div>
+
+      <!-- 转化列表表格 -->
+      <el-table
+        :data="conversionList"
+        style="width: 100%; margin-top: 20px"
+        v-loading="loading"
+        stripe
+      >
+        <el-table-column prop="conversionId" label="转化ID" width="150" />
+        <el-table-column prop="conversionName" label="转化名称" min-width="250" show-overflow-tooltip />
+        <el-table-column label="创建时间" width="180">
+          <template #default="{ row }">
+            {{ formatDate(row.createTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="更新时间" width="180">
+          <template #default="{ row }">
+            {{ formatDate(row.updateTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              type="primary"
+              size="small"
+              link
+              @click="handleViewDetail(row)"
+            >
+              <el-icon><View /></el-icon>
+              查看详情
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="mt-6 flex justify-end">
+        <el-pagination
+          v-model:current-page="pagination.page"
+          v-model:page-size="pagination.pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="pagination.total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </div>
+
+    <!-- 详情对话框 -->
+    <el-dialog
+      v-model="detailDialogVisible"
+      title="转化归因详情"
+      width="600px"
+    >
+      <el-descriptions :column="1" border v-if="currentConversion">
+        <el-descriptions-item label="转化ID">
+          {{ currentConversion.conversionId }}
+        </el-descriptions-item>
+        <el-descriptions-item label="转化名称">
+          {{ currentConversion.conversionName || '-' }}
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ formatDate(currentConversion.createTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="更新时间">
+          {{ formatDate(currentConversion.updateTime) }}
+        </el-descriptions-item>
+      </el-descriptions>
+      
+      <template #footer>
+        <el-button @click="detailDialogVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Refresh, View, DataAnalysis, Files, Document } from '@element-plus/icons-vue'
+import { syncConversions, getConversionList, getConversionDetail } from '@/api/conversion'
+
+// 响应式数据
+const loading = ref(false)
+const syncing = ref(false)
+const syncOrgAccountId = ref('')
+const syncAccountId = ref('')
+const conversionList = ref([])
+const detailDialogVisible = ref(false)
+const currentConversion = ref(null)
+
+// 分页数据
+const pagination = reactive({
+  page: 1,
+  pageSize: 20,
+  total: 0
+})
+
+// 计算属性
+const totalPages = computed(() => {
+  return Math.ceil(pagination.total / pagination.pageSize)
+})
+
+// 格式化日期
+const formatDate = (dateStr) => {
+  if (!dateStr) return '-'
+  const date = new Date(dateStr)
+  return date.toLocaleString('zh-CN', {
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+    hour: '2-digit',
+    minute: '2-digit',
+    second: '2-digit'
+  })
+}
+
+// 加载转化列表
+const loadConversionList = async () => {
+  loading.value = true
+  try {
+    const response = await getConversionList(pagination.page, pagination.pageSize)
+    if (response.success) {
+      conversionList.value = response.data || []
+      pagination.total = response.total || 0
+    } else {
+      ElMessage.error(response.message || '加载转化列表失败')
+    }
+  } catch (error) {
+    console.error('加载转化列表失败:', error)
+    ElMessage.error('加载转化列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+// 同步转化
+const handleSync = async () => {
+  if (!syncOrgAccountId.value) {
+    ElMessage.warning('请输入组织账户ID')
+    return
+  }
+  if (!syncAccountId.value) {
+    ElMessage.warning('请输入广告账户ID')
+    return
+  }
+
+  try {
+    await ElMessageBox.confirm(
+      `确定要同步组织账户 ${syncOrgAccountId.value} 下的广告账户 ${syncAccountId.value} 的转化归因数据吗?`,
+      '确认同步',
+      {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }
+    )
+
+    syncing.value = true
+    const response = await syncConversions(Number(syncOrgAccountId.value), Number(syncAccountId.value))
+    if (response.success) {
+      ElMessage.success(response.message || '同步成功')
+      // 重新加载列表
+      await loadConversionList()
+    } else {
+      ElMessage.error(response.message || '同步失败')
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('同步转化失败:', error)
+      ElMessage.error('同步失败')
+    }
+  } finally {
+    syncing.value = false
+  }
+}
+
+// 查看详情
+const handleViewDetail = async (row) => {
+  try {
+    const response = await getConversionDetail(row.conversionId)
+    if (response.code === 0) {
+      currentConversion.value = response.data
+      detailDialogVisible.value = true
+    } else {
+      ElMessage.error(response.message || '查询详情失败')
+    }
+  } catch (error) {
+    console.error('查询详情失败:', error)
+    ElMessage.error('查询详情失败')
+  }
+}
+
+// 分页大小改变
+const handleSizeChange = (newSize) => {
+  pagination.pageSize = newSize
+  pagination.page = 1
+  loadConversionList()
+}
+
+// 当前页改变
+const handleCurrentChange = (newPage) => {
+  pagination.page = newPage
+  loadConversionList()
+}
+
+// 页面加载时获取列表
+onMounted(() => {
+  loadConversionList()
+})
+</script>
+
+<style scoped>
+.conversion-container {
+  padding: 20px;
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.stats-row {
+  margin-bottom: 20px;
+}
+
+.pagination-container {
+  margin-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+:deep(.el-statistic__head) {
+  font-size: 14px;
+  color: #909399;
+}
+
+:deep(.el-statistic__number) {
+  font-size: 24px;
+  font-weight: bold;
+  color: #303133;
+}
+
+:deep(.success .el-statistic__number) {
+  color: #67c23a;
+}
+
+:deep(.info .el-statistic__number) {
+  color: #409eff;
+}
+
+:deep(.warning .el-statistic__number) {
+  color: #e6a23c;
+}
+</style>

+ 154 - 18
portal/src/views/dashboard/index.vue

@@ -1,24 +1,160 @@
 <template>
-  <div class="dashboard-container">
-    <el-card>
-      <template #header>
-        <span>欢迎使用腾讯广告自动化管理系统</span>
-      </template>
-      <el-empty description="控制台功能开发中..." />
-    </el-card>
+  <div class="space-y-6">
+    <!-- 欢迎卡片 -->
+    <div class="bg-gradient-to-r from-blue-500 to-purple-600 rounded-2xl p-8 text-white shadow-lg">
+      <div class="flex items-center justify-between">
+        <div>
+          <h1 class="text-3xl font-bold mb-2">欢迎使用腾讯广告自动化管理系统</h1>
+          <p class="text-blue-100 text-lg">一站式广告投放与资产管理平台</p>
+        </div>
+        <div class="text-5xl">🚀</div>
+      </div>
+    </div>
+    
+    <!-- 统计卡片 -->
+    <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
+      <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow">
+        <div class="flex items-center">
+          <div class="p-3 rounded-lg bg-blue-100 text-blue-600 mr-4">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
+            </svg>
+          </div>
+          <div>
+            <p class="text-sm text-gray-500">视频素材</p>
+            <p class="text-2xl font-bold text-gray-900">128</p>
+          </div>
+        </div>
+      </div>
+      
+      <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow">
+        <div class="flex items-center">
+          <div class="p-3 rounded-lg bg-green-100 text-green-600 mr-4">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
+            </svg>
+          </div>
+          <div>
+            <p class="text-sm text-gray-500">图片素材</p>
+            <p class="text-2xl font-bold text-gray-900">86</p>
+          </div>
+        </div>
+      </div>
+      
+      <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow">
+        <div class="flex items-center">
+          <div class="p-3 rounded-lg bg-purple-100 text-purple-600 mr-4">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
+            </svg>
+          </div>
+          <div>
+            <p class="text-sm text-gray-500">广告组</p>
+            <p class="text-2xl font-bold text-gray-900">24</p>
+          </div>
+        </div>
+      </div>
+      
+      <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow">
+        <div class="flex items-center">
+          <div class="p-3 rounded-lg bg-yellow-100 text-yellow-600 mr-4">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
+            </svg>
+          </div>
+          <div>
+            <p class="text-sm text-gray-500">转化数据</p>
+            <p class="text-2xl font-bold text-gray-900">1,245</p>
+          </div>
+        </div>
+      </div>
+    </div>
+    
+    <!-- 功能快捷入口 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <h2 class="text-xl font-semibold text-gray-800 mb-6">快速开始</h2>
+      <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
+        <router-link to="/video" class="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-colors group">
+          <div class="p-3 rounded-lg bg-blue-100 text-blue-600 mb-3 group-hover:bg-blue-200 transition-colors">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
+            </svg>
+          </div>
+          <span class="text-gray-700 font-medium">视频管理</span>
+        </router-link>
+        
+        <router-link to="/image" class="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:border-green-300 hover:bg-green-50 transition-colors group">
+          <div class="p-3 rounded-lg bg-green-100 text-green-600 mb-3 group-hover:bg-green-200 transition-colors">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
+            </svg>
+          </div>
+          <span class="text-gray-700 font-medium">图片管理</span>
+        </router-link>
+        
+        <router-link to="/adgroup" class="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-colors group">
+          <div class="p-3 rounded-lg bg-purple-100 text-purple-600 mb-3 group-hover:bg-purple-200 transition-colors">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
+            </svg>
+          </div>
+          <span class="text-gray-700 font-medium">广告组</span>
+        </router-link>
+        
+        <router-link to="/conversion" class="flex flex-col items-center p-4 rounded-lg border border-gray-200 hover:border-yellow-300 hover:bg-yellow-50 transition-colors group">
+          <div class="p-3 rounded-lg bg-yellow-100 text-yellow-600 mb-3 group-hover:bg-yellow-200 transition-colors">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
+            </svg>
+          </div>
+          <span class="text-gray-700 font-medium">转化数据</span>
+        </router-link>
+      </div>
+    </div>
+    
+    <!-- 系统状态 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <h2 class="text-xl font-semibold text-gray-800 mb-6">系统状态</h2>
+      <div class="grid grid-cols-1 md:grid-cols-3 gap-6">
+        <div class="flex items-center p-4 bg-green-50 rounded-lg border border-green-200">
+          <div class="p-2 rounded-full bg-green-200 mr-4">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-green-600" viewBox="0 0 20 20" fill="currentColor">
+              <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
+            </svg>
+          </div>
+          <div>
+            <p class="font-medium text-green-800">服务正常</p>
+            <p class="text-sm text-green-600">所有服务运行正常</p>
+          </div>
+        </div>
+        
+        <div class="flex items-center p-4 bg-blue-50 rounded-lg border border-blue-200">
+          <div class="p-2 rounded-full bg-blue-200 mr-4">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
+            </svg>
+          </div>
+          <div>
+            <p class="font-medium text-blue-800">更新时间</p>
+            <p class="text-sm text-blue-600">2026-02-06 18:30</p>
+          </div>
+        </div>
+        
+        <div class="flex items-center p-4 bg-purple-50 rounded-lg border border-purple-200">
+          <div class="p-2 rounded-full bg-purple-200 mr-4">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
+            </svg>
+          </div>
+          <div>
+            <p class="font-medium text-purple-800">版本号</p>
+            <p class="text-sm text-purple-600">v1.0.0</p>
+          </div>
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 
 <script setup>
 </script>
-
-<style scoped lang="scss">
-.dashboard-container {
-  :deep(.el-card__body) {
-    min-height: 400px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-}
-</style>

+ 312 - 0
portal/src/views/drama/index.vue

@@ -0,0 +1,312 @@
+<template>
+  <div class="space-y-6">
+    <!-- 主卡片 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <div class="border-b border-gray-200 pb-4 mb-6">
+        <div class="flex items-center justify-between">
+          <h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 12h18M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z" />
+            </svg>
+            短剧资产列表
+          </h2>
+          <div class="flex items-center gap-3">
+            <el-input
+              v-model="orgAccountId"
+              placeholder="组织账户ID"
+              clearable
+              style="width: 200px"
+            />
+            <el-input
+              v-model="phone"
+              placeholder="手机号"
+              clearable
+              style="width: 150px"
+            />
+            <el-input
+              v-model="accountId"
+              placeholder="推广账号ID"
+              clearable
+              style="width: 200px"
+            />
+            <el-button 
+              type="primary" 
+              @click="handleSync"
+              :loading="syncing"
+              :disabled="!orgAccountId || !phone || !accountId"
+            >
+              <el-icon><Refresh /></el-icon>
+              同步资产
+            </el-button>
+          </div>
+        </div>
+      </div>
+
+      <!-- 数据统计 -->
+      <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
+        <div class="bg-gradient-to-br from-indigo-50 to-indigo-100 rounded-lg p-4 border border-indigo-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-indigo-600 mb-1">总资产数</p>
+              <p class="text-2xl font-bold text-indigo-700">{{ total }}</p>
+            </div>
+            <div class="p-3 bg-indigo-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
+              </svg>
+            </div>
+          </div>
+        </div>
+        <div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4 border border-blue-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-blue-600 mb-1">当前页数量</p>
+              <p class="text-2xl font-bold text-blue-700">{{ dramaList.length }}</p>
+            </div>
+            <div class="p-3 bg-blue-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+              </svg>
+            </div>
+          </div>
+        </div>
+        <div class="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4 border border-green-200">
+          <div class="flex items-center justify-between">
+            <div>
+              <p class="text-sm text-green-600 mb-1">总页数</p>
+              <p class="text-2xl font-bold text-green-700">{{ totalPages }}</p>
+            </div>
+            <div class="p-3 bg-green-200 rounded-lg">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
+              </svg>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 短剧资产列表表格 -->
+      <el-table
+        :data="dramaList"
+        style="width: 100%"
+        v-loading="loading"
+        stripe
+        border
+      >
+        <el-table-column prop="marketingAssetId" label="资产ID" width="150" />
+        <el-table-column prop="marketingAssetName" label="短剧名称" width="300" show-overflow-tooltip />
+        <el-table-column prop="marketingAssetType" label="资产类型" width="200" />
+        <el-table-column label="创建时间" width="180">
+          <template #default="{ row }">
+            {{ formatTimestamp(row.createdTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="更新时间" width="180">
+          <template #default="{ row }">
+            {{ formatDate(row.updateTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="150" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              type="primary"
+              size="small"
+              link
+              @click="handleViewDetail(row)"
+            >
+              <el-icon><View /></el-icon>
+              查看详情
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      
+      <!-- 分页组件 -->
+      <div class="mt-6 flex justify-end">
+        <el-pagination
+          v-if="total > 0"
+          v-model:current-page="currentPage"
+          v-model:page-size="pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+          style="justify-content: flex-end"
+        />
+      </div>
+    </div>
+
+    <!-- 短剧资产详情对话框 -->
+    <el-dialog
+      v-model="detailDialogVisible"
+      title="短剧资产详情"
+      width="700px"
+    >
+      <el-descriptions :column="1" border v-if="currentDrama">
+        <el-descriptions-item label="资产ID">
+          {{ currentDrama.marketingAssetId }}
+        </el-descriptions-item>
+        <el-descriptions-item label="短剧名称">
+          {{ currentDrama.marketingAssetName }}
+        </el-descriptions-item>
+        <el-descriptions-item label="资产类型">
+          {{ currentDrama.marketingAssetType }}
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">
+          {{ formatTimestamp(currentDrama.createdTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="记录创建时间">
+          {{ formatDate(currentDrama.createTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="记录更新时间">
+          {{ formatDate(currentDrama.updateTime) }}
+        </el-descriptions-item>
+        <el-descriptions-item label="资产属性">
+          <pre v-if="currentDrama.properties" style="margin: 0; white-space: pre-wrap; word-break: break-all; max-height: 400px; overflow-y: auto;">{{ formatJSON(currentDrama.properties) }}</pre>
+          <span v-else>无</span>
+        </el-descriptions-item>
+      </el-descriptions>
+      <template #footer>
+        <el-button @click="detailDialogVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { getDramaList, syncDrama } from '@/api/drama'
+import {
+  Refresh,
+  VideoPlay,
+  Document,
+  Files,
+  View
+} from '@element-plus/icons-vue'
+
+// 数据
+const loading = ref(false)
+const syncing = ref(false)
+const dramaList = ref([])
+const detailDialogVisible = ref(false)
+const currentDrama = ref(null)
+
+// 同步参数
+const orgAccountId = ref('')
+const phone = ref('')
+const accountId = ref('')
+
+// 分页数据
+const currentPage = ref(1)
+const pageSize = ref(20)
+const total = ref(0)
+
+// 计算属性
+const totalPages = computed(() => {
+  return Math.ceil(total.value / pageSize.value)
+})
+
+// 方法
+const loadDramaList = async () => {
+  loading.value = true
+  try {
+    const res = await getDramaList(currentPage.value, pageSize.value)
+    if (res.success) {
+      dramaList.value = res.data || []
+      total.value = res.total || 0
+    } else {
+      ElMessage.error(res.message || '加载短剧资产列表失败')
+    }
+  } catch (error) {
+    console.error('加载短剧资产列表失败:', error)
+    ElMessage.error('加载短剧资产列表失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+// 分页方法
+const handleSizeChange = async (newSize) => {
+  pageSize.value = newSize
+  currentPage.value = 1
+  await loadDramaList()
+}
+
+const handleCurrentChange = async (newPage) => {
+  currentPage.value = newPage
+  await loadDramaList()
+}
+
+const handleSync = async () => {
+  if (!orgAccountId.value || !phone.value || !accountId.value) {
+    ElMessage.warning('请填写所有同步参数')
+    return
+  }
+
+  try {
+    await ElMessageBox.confirm(
+      '确定要同步该账户下的短剧资产吗?',
+      '提示',
+      {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }
+    )
+
+    syncing.value = true
+    const res = await syncDrama(orgAccountId.value, phone.value, accountId.value)
+    
+    if (res.success) {
+      ElMessage.success(res.message || '同步成功')
+      // 重新加载资产列表
+      currentPage.value = 1 // 重置到第一页
+      await loadDramaList()
+    } else {
+      ElMessage.error(res.message || '同步失败')
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('同步短剧资产失败:', error)
+      ElMessage.error('同步短剧资产失败')
+    }
+  } finally {
+    syncing.value = false
+  }
+}
+
+const handleViewDetail = (drama) => {
+  currentDrama.value = drama
+  detailDialogVisible.value = true
+}
+
+const formatDate = (dateStr) => {
+  if (!dateStr) return '-'
+  return new Date(dateStr).toLocaleString('zh-CN')
+}
+
+const formatTimestamp = (timestamp) => {
+  if (!timestamp) return '-'
+  return new Date(timestamp * 1000).toLocaleString('zh-CN')
+}
+
+const formatJSON = (jsonStr) => {
+  if (!jsonStr) return ''
+  try {
+    return JSON.stringify(JSON.parse(jsonStr), null, 2)
+  } catch {
+    return jsonStr
+  }
+}
+
+// 生命周期
+onMounted(() => {
+  loadDramaList()
+})
+</script>
+
+<style scoped>
+/* 使用Tailwind CSS类替代自定义样式 */
+</style>

+ 466 - 0
portal/src/views/image/index.vue

@@ -0,0 +1,466 @@
+<template>
+  <div class="space-y-6">
+    <!-- 上传卡片 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <div class="border-b border-gray-200 pb-4 mb-6">
+        <h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
+          <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
+          </svg>
+          图片上传
+        </h2>
+      </div>
+
+      <!-- 上传表单 -->
+      <el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="120px">
+        <el-form-item label="广告账户ID" prop="accountId">
+          <el-input 
+            v-model.number="uploadForm.accountId" 
+            placeholder="请输入广告账户ID" 
+            clearable
+            style="width: 100%">
+          </el-input>
+        </el-form-item>
+
+        <el-form-item label="选择图片" prop="file">
+          <el-upload
+            ref="uploadRef"
+            :auto-upload="false"
+            :limit="1"
+            :on-change="handleFileChange"
+            :on-exceed="handleExceed"
+            :file-list="fileList"
+            accept="image/jpeg,image/jpg,image/png"
+            drag>
+            <el-icon class="el-icon--upload"><upload-filled /></el-icon>
+            <div class="el-upload__text">
+              拖拽文件到此处或 <em>点击上传</em>
+            </div>
+            <template #tip>
+              <div class="el-upload__tip">
+                支持JPG、JPEG、PNG格式,文件大小不超过10MB
+              </div>
+            </template>
+          </el-upload>
+        </el-form-item>
+
+        <el-form-item label="是否添加水印">
+          <el-switch v-model="uploadForm.hasWatermark" @change="onWatermarkChange"></el-switch>
+        </el-form-item>
+
+        <el-form-item label="选择剧" prop="dramaId" v-if="uploadForm.hasWatermark">
+          <el-select 
+            v-model="uploadForm.dramaId" 
+            placeholder="请选择剧" 
+            filterable 
+            style="width: 100%">
+            <el-option
+              v-for="drama in dramaList"
+              :key="drama.id"
+              :label="drama.marketingAssetName"
+              :value="drama.id">
+            </el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="图片描述">
+          <el-input 
+            v-model="uploadForm.description" 
+            type="textarea" 
+            :rows="3"
+            placeholder="请输入图片描述(可选)"
+            maxlength="500"
+            show-word-limit>
+          </el-input>
+        </el-form-item>
+
+        <div class="flex gap-3 pt-4">
+          <el-button type="primary" @click="submitUpload" :loading="uploading" class="btn-primary">
+            {{ uploading ? '上传中...' : '上传图片' }}
+          </el-button>
+          <el-button @click="resetForm" class="btn-secondary">重置</el-button>
+        </div>
+      </el-form>
+    </div>
+
+    <!-- 图片列表 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <div class="flex items-center justify-between mb-6 pb-4 border-b border-gray-200">
+        <h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
+          <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
+          </svg>
+          上传记录
+        </h2>
+        <el-button type="primary" size="small" @click="loadImageList">
+          <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+          </svg>
+          刷新
+        </el-button>
+      </div>
+
+      <!-- 查询条件 -->
+      <el-form :inline="true" :model="queryForm" class="query-form">
+        <el-form-item label="账户ID">
+          <el-input v-model="queryForm.accountId" placeholder="账户ID" clearable style="width: 150px"></el-input>
+        </el-form-item>
+        <el-form-item label="上传状态">
+          <el-select v-model="queryForm.uploadStatus" placeholder="全部" clearable style="width: 120px">
+            <el-option label="成功" value="SUCCESS"></el-option>
+            <el-option label="失败" value="FAILED"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleQuery">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <!-- 表格 -->
+      <el-table :data="imageList" border style="width: 100%" v-loading="tableLoading">
+        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="accountId" label="账户ID" width="120"></el-table-column>
+        <el-table-column prop="originalFilename" label="文件名" width="200" show-overflow-tooltip></el-table-column>
+        <el-table-column label="预览" width="120">
+          <template #default="scope">
+            <el-image 
+              v-if="scope.row.previewUrl"
+              style="width: 80px; height: 60px"
+              :src="scope.row.previewUrl"
+              :preview-src-list="[scope.row.previewUrl]"
+              fit="cover">
+            </el-image>
+          </template>
+        </el-table-column>
+        <el-table-column prop="imageId" label="腾讯图片ID" width="150"></el-table-column>
+        <el-table-column label="尺寸" width="120">
+          <template #default="scope">
+            {{ scope.row.imageWidth }}x{{ scope.row.imageHeight }}
+          </template>
+        </el-table-column>
+        <el-table-column label="大小" width="100">
+          <template #default="scope">
+            {{ formatFileSize(scope.row.imageFileSize) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="水印" width="100">
+          <template #default="scope">
+            <el-tag v-if="scope.row.hasWatermark" type="success" size="small">已添加</el-tag>
+            <el-tag v-else type="info" size="small">未添加</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="watermarkText" label="水印文字" width="150" show-overflow-tooltip></el-table-column>
+        <el-table-column label="状态" width="80">
+          <template #default="scope">
+            <el-tag v-if="scope.row.uploadStatus === 'SUCCESS'" type="success" size="small">成功</el-tag>
+            <el-tag v-else type="danger" size="small">失败</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createdTime" label="创建时间" width="180"></el-table-column>
+        <el-table-column label="操作" width="120" fixed="right">
+          <template #default="scope">
+            <el-button type="primary" size="small" link @click="viewDetail(scope.row)">详情</el-button>
+            <el-button type="danger" size="small" link @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="mt-6 flex justify-center">
+        <el-pagination
+          v-model:current-page="pagination.page"
+          v-model:page-size="pagination.size"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="pagination.total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange">
+        </el-pagination>
+      </div>
+    </div>
+
+    <!-- 详情对话框 -->
+    <el-dialog v-model="detailDialogVisible" title="图片详情" width="800px">
+      <el-descriptions :column="2" border v-if="currentImage">
+        <el-descriptions-item label="ID">{{ currentImage.id }}</el-descriptions-item>
+        <el-descriptions-item label="账户ID">{{ currentImage.accountId }}</el-descriptions-item>
+        <el-descriptions-item label="腾讯图片ID">{{ currentImage.imageId }}</el-descriptions-item>
+        <el-descriptions-item label="文件名">{{ currentImage.originalFilename }}</el-descriptions-item>
+        <el-descriptions-item label="图片宽度">{{ currentImage.imageWidth }}px</el-descriptions-item>
+        <el-descriptions-item label="图片高度">{{ currentImage.imageHeight }}px</el-descriptions-item>
+        <el-descriptions-item label="图片大小">{{ formatFileSize(currentImage.imageFileSize) }}</el-descriptions-item>
+        <el-descriptions-item label="图片类型">{{ currentImage.imageType }}</el-descriptions-item>
+        <el-descriptions-item label="是否添加水印">
+          <el-tag v-if="currentImage.hasWatermark" type="success">是</el-tag>
+          <el-tag v-else type="info">否</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="水印文字">{{ currentImage.watermarkText || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="上传状态">
+          <el-tag v-if="currentImage.uploadStatus === 'SUCCESS'" type="success">成功</el-tag>
+          <el-tag v-else type="danger">失败</el-tag>
+        </el-descriptions-item>
+        <el-descriptions-item label="创建时间">{{ currentImage.createdTime }}</el-descriptions-item>
+        <el-descriptions-item label="描述" :span="2">{{ currentImage.description || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="预览地址" :span="2">
+          <el-link :href="currentImage.previewUrl" target="_blank" type="primary">{{ currentImage.previewUrl }}</el-link>
+        </el-descriptions-item>
+        <el-descriptions-item label="预览图" :span="2">
+          <el-image 
+            v-if="currentImage.previewUrl"
+            style="width: 100%; max-width: 600px"
+            :src="currentImage.previewUrl"
+            :preview-src-list="[currentImage.previewUrl]"
+            fit="contain">
+          </el-image>
+        </el-descriptions-item>
+        <el-descriptions-item label="错误信息" :span="2" v-if="currentImage.errorMessage">
+          <el-text type="danger">{{ currentImage.errorMessage }}</el-text>
+        </el-descriptions-item>
+      </el-descriptions>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { UploadFilled } from '@element-plus/icons-vue'
+import { uploadImage, getImageList, deleteImage } from '../../api/image'
+import { getDramaList } from '../../api/drama'
+
+// 上传表单
+const uploadFormRef = ref()
+const uploadForm = reactive({
+  accountId: null,
+  file: null,
+  hasWatermark: false,
+  dramaId: null,
+  description: ''
+})
+
+// 表单验证规则
+const uploadRules = {
+  accountId: [
+    { required: true, message: '请输入广告账户ID', trigger: 'blur' },
+    { type: 'number', message: '广告账户ID必须为数字', trigger: 'blur' }
+  ],
+  file: [{ required: true, message: '请选择图片文件', trigger: 'change' }],
+  dramaId: [{ required: true, message: '请选择剧', trigger: 'change' }]
+}
+
+// 文件列表
+const fileList = ref([])
+const uploadRef = ref()
+
+// 剧列表
+const dramaList = ref([])
+
+// 上传中
+const uploading = ref(false)
+
+// 查询表单
+const queryForm = reactive({
+  accountId: null,
+  uploadStatus: ''
+})
+
+// 图片列表
+const imageList = ref([])
+const tableLoading = ref(false)
+
+// 分页
+const pagination = reactive({
+  page: 1,
+  size: 10,
+  total: 0
+})
+
+// 详情对话框
+const detailDialogVisible = ref(false)
+const currentImage = ref(null)
+
+// 加载剧列表
+const loadDramaList = async () => {
+  try {
+    const res = await getDramaList({ page: 1, size: 1000 })
+    if (res.success) {
+      dramaList.value = res.data || []
+    }
+  } catch (error) {
+    console.error('加载剧列表失败', error)
+  }
+}
+
+// 水印开关变化
+const onWatermarkChange = (value) => {
+  if (!value) {
+    uploadForm.dramaId = null
+  }
+}
+
+// 文件变化
+const handleFileChange = (file) => {
+  uploadForm.file = file.raw
+  fileList.value = [file]
+}
+
+// 文件超出限制
+const handleExceed = () => {
+  ElMessage.warning('只能上传一个文件')
+}
+
+// 提交上传
+const submitUpload = async () => {
+  try {
+    await uploadFormRef.value.validate()
+
+    if (!uploadForm.file) {
+      ElMessage.error('请选择图片文件')
+      return
+    }
+
+    if (uploadForm.hasWatermark && !uploadForm.dramaId) {
+      ElMessage.error('添加水印时必须选择剧')
+      return
+    }
+
+    uploading.value = true
+
+    const formData = new FormData()
+    formData.append('file', uploadForm.file)
+    formData.append('accountId', uploadForm.accountId)
+    formData.append('hasWatermark', uploadForm.hasWatermark)
+    if (uploadForm.dramaId) {
+      formData.append('dramaId', uploadForm.dramaId)
+    }
+    if (uploadForm.description) {
+      formData.append('description', uploadForm.description)
+    }
+
+    const res = await uploadImage(formData)
+    if (res.code === 200) {
+      ElMessage.success('图片上传成功')
+      resetForm()
+      loadImageList()
+    } else {
+      ElMessage.error(res.message || '图片上传失败')
+    }
+  } catch (error) {
+    console.error('上传失败', error)
+    if (error !== false) {  // 不是表单验证错误
+      ElMessage.error('上传失败: ' + (error.message || '未知错误'))
+    }
+  } finally {
+    uploading.value = false
+  }
+}
+
+// 重置表单
+const resetForm = () => {
+  uploadFormRef.value?.resetFields()
+  uploadForm.file = null
+  fileList.value = []
+  uploadRef.value?.clearFiles()
+}
+
+// 加载图片列表
+const loadImageList = async () => {
+  try {
+    tableLoading.value = true
+    const params = {
+      page: pagination.page,
+      size: pagination.size
+    }
+    if (queryForm.accountId) {
+      params.accountId = queryForm.accountId
+    }
+    if (queryForm.uploadStatus) {
+      params.uploadStatus = queryForm.uploadStatus
+    }
+
+    const res = await getImageList(params)
+    if (res.code === 200) {
+      imageList.value = res.data.records || []
+      pagination.total = res.data.total || 0
+    }
+  } catch (error) {
+    console.error('加载图片列表失败', error)
+    ElMessage.error('加载图片列表失败')
+  } finally {
+    tableLoading.value = false
+  }
+}
+
+// 查询
+const handleQuery = () => {
+  pagination.page = 1
+  loadImageList()
+}
+
+// 重置查询
+const handleReset = () => {
+  queryForm.accountId = null
+  queryForm.uploadStatus = ''
+  pagination.page = 1
+  loadImageList()
+}
+
+// 分页大小变化
+const handleSizeChange = () => {
+  loadImageList()
+}
+
+// 页码变化
+const handlePageChange = () => {
+  loadImageList()
+}
+
+// 查看详情
+const viewDetail = (row) => {
+  currentImage.value = row
+  detailDialogVisible.value = true
+}
+
+// 删除
+const handleDelete = async (row) => {
+  try {
+    await ElMessageBox.confirm('确定要删除这条记录吗?', '提示', {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning'
+    })
+
+    const res = await deleteImage(row.id)
+    if (res.code === 200) {
+      ElMessage.success('删除成功')
+      loadImageList()
+    } else {
+      ElMessage.error(res.message || '删除失败')
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('删除失败', error)
+      ElMessage.error('删除失败')
+    }
+  }
+}
+
+// 格式化文件大小
+const formatFileSize = (bytes) => {
+  if (!bytes) return '-'
+  if (bytes < 1024) return bytes + 'B'
+  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + 'KB'
+  return (bytes / 1024 / 1024).toFixed(2) + 'MB'
+}
+
+// 初始化
+onMounted(() => {
+  loadDramaList()
+  loadImageList()
+})
+</script>
+
+<style scoped>
+/* 使用Tailwind CSS类替代自定义样式 */
+</style>

+ 448 - 0
portal/src/views/video/index.vue

@@ -0,0 +1,448 @@
+<template>
+  <div class="space-y-6">
+    <!-- 上传卡片 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <div class="border-b border-gray-200 pb-4 mb-6">
+        <h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
+          <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
+          </svg>
+          视频上传
+        </h2>
+      </div>
+
+      <!-- 上传表单 -->
+      <el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-width="120px">
+        <el-form-item label="广告账户ID" prop="accountId">
+          <el-input v-model.number="uploadForm.accountId" placeholder="请输入广告账户ID" />
+        </el-form-item>
+
+        <el-form-item label="视频文件" prop="file">
+          <el-upload
+            ref="uploadRef"
+            :auto-upload="false"
+            :limit="1"
+            :on-change="handleFileChange"
+            :on-exceed="handleExceed"
+            :before-remove="handleBeforeRemove"
+            accept=".mp4,.mov,.avi">
+            <template #trigger>
+              <el-button type="primary">选择视频文件</el-button>
+            </template>
+            <template #tip>
+              <div class="el-upload__tip">
+                支持MP4、MOV、AVI格式,文件大小不超过100MB
+              </div>
+            </template>
+          </el-upload>
+        </el-form-item>
+
+        <el-form-item label="是否添加水印">
+          <el-switch v-model="uploadForm.hasWatermark" @change="handleWatermarkChange" />
+        </el-form-item>
+
+        <el-form-item v-if="uploadForm.hasWatermark" label="选择短剧" prop="dramaId">
+          <el-select
+            v-model="uploadForm.dramaId"
+            filterable
+            placeholder="请选择短剧"
+            style="width: 100%"
+            :loading="dramaLoading">
+            <el-option
+              v-for="item in dramaList"
+              :key="item.id"
+              :label="item.marketingAssetName"
+              :value="item.id">
+            </el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item v-if="uploadForm.hasWatermark" label="水印时长(秒)">
+          <el-input-number
+            v-model="uploadForm.watermarkDuration"
+            :min="1"
+            :max="3600"
+            placeholder="不填则全程显示" />
+          <span style="margin-left: 10px; color: #909399; font-size: 12px;">
+            不填则水印全程显示
+          </span>
+        </el-form-item>
+
+        <el-form-item label="视频描述">
+          <el-input
+            v-model="uploadForm.description"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入视频描述(可选)"
+            maxlength="500"
+            show-word-limit />
+        </el-form-item>
+
+        <el-form-item>
+          <el-button type="primary" :loading="uploading" @click="handleUpload">
+            {{ uploading ? '上传中...' : '开始上传' }}
+          </el-button>
+          <el-button @click="resetForm">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <!-- 上传进度 -->
+      <div v-if="uploading" class="mt-6">
+        <div class="mb-2 text-sm text-gray-600">上传进度</div>
+        <el-progress
+          :percentage="uploadProgress"
+          :status="uploadStatus"
+          :show-text="true" />
+      </div>
+    </div>
+
+    <!-- 视频列表 -->
+    <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
+      <div class="flex items-center justify-between mb-6 pb-4 border-b border-gray-200">
+        <h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
+          <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
+          </svg>
+          视频列表
+        </h2>
+        <el-button type="primary" size="small" @click="loadVideoList">
+          <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+          </svg>
+          刷新
+        </el-button>
+      </div>
+
+      <!-- 筛选条件 -->
+      <el-form :inline="true" style="margin-bottom: 20px;">
+        <el-form-item label="账户ID">
+          <el-input v-model.number="queryForm.accountId" placeholder="请输入账户ID" clearable />
+        </el-form-item>
+        <el-form-item label="上传状态">
+          <el-select v-model="queryForm.uploadStatus" placeholder="请选择状态" clearable>
+            <el-option label="成功" value="SUCCESS" />
+            <el-option label="失败" value="FAILED" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleQuery">查询</el-button>
+          <el-button @click="handleResetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <!-- 表格 -->
+      <el-table :data="videoList" border style="width: 100%" v-loading="tableLoading">
+        <el-table-column prop="id" label="ID" width="80" />
+        <el-table-column prop="accountId" label="账户ID" width="120" />
+        <el-table-column prop="originalFilename" label="文件名" width="200" show-overflow-tooltip />
+        <el-table-column prop="videoId" label="视频ID" width="150" />
+        <el-table-column prop="coverImageId" label="封面图片ID" width="150" />
+        <el-table-column prop="videoDuration" label="时长(秒)" width="100" />
+        <el-table-column label="是否水印" width="100">
+          <template #default="scope">
+            <el-tag :type="scope.row.hasWatermark ? 'success' : 'info'" size="small">
+              {{ scope.row.hasWatermark ? '是' : '否' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="watermarkText" label="水印文字" width="150" show-overflow-tooltip />
+        <el-table-column prop="watermarkDuration" label="水印时长(秒)" width="120">
+          <template #default="scope">
+            {{ scope.row.watermarkDuration || '全程' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="上传状态" width="100">
+          <template #default="scope">
+            <el-tag :type="scope.row.uploadStatus === 'SUCCESS' ? 'success' : 'danger'" size="small">
+              {{ scope.row.uploadStatus === 'SUCCESS' ? '成功' : '失败' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="errorMessage" label="错误信息" min-width="200" show-overflow-tooltip />
+        <el-table-column prop="createdTime" label="创建时间" width="180" />
+        <el-table-column label="操作" width="100" fixed="right">
+          <template #default="scope">
+            <el-button type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <div class="mt-6 flex justify-end">
+        <el-pagination
+          v-model:current-page="pagination.page"
+          v-model:page-size="pagination.size"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="pagination.total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { uploadVideo, getVideoList, deleteVideo } from '@/api/video'
+import { getDramaList } from '@/api/drama'
+
+// 上传表单
+const uploadFormRef = ref(null)
+const uploadRef = ref(null)
+const uploadForm = reactive({
+  accountId: null,
+  file: null,
+  hasWatermark: false,
+  dramaId: null,
+  watermarkDuration: null,
+  description: ''
+})
+
+// 表单验证规则
+const uploadRules = {
+  accountId: [
+    { required: true, message: '请输入广告账户ID', trigger: 'blur' },
+    { type: 'number', message: '账户ID必须为数字', trigger: 'blur' }
+  ],
+  file: [
+    { required: true, message: '请选择视频文件', trigger: 'change' }
+  ],
+  dramaId: [
+    { required: true, message: '请选择短剧', trigger: 'change' }
+  ]
+}
+
+// 上传状态
+const uploading = ref(false)
+const uploadProgress = ref(0)
+const uploadStatus = ref('')
+
+// 短剧列表
+const dramaList = ref([])
+const dramaLoading = ref(false)
+
+// 查询表单
+const queryForm = reactive({
+  accountId: null,
+  uploadStatus: null
+})
+
+// 视频列表
+const videoList = ref([])
+const tableLoading = ref(false)
+
+// 分页
+const pagination = reactive({
+  page: 1,
+  size: 10,
+  total: 0
+})
+
+// 加载短剧列表
+const loadDramaList = async () => {
+  dramaLoading.value = true
+  try {
+    const res = await getDramaList(1, 1000)
+    if (res.success && res.data) {
+      dramaList.value = res.data || []
+    }
+  } catch (error) {
+    console.error('加载短剧列表失败', error)
+  } finally {
+    dramaLoading.value = false
+  }
+}
+
+// 水印开关变化
+const handleWatermarkChange = (value) => {
+  if (value && dramaList.value.length === 0) {
+    loadDramaList()
+  }
+  if (!value) {
+    uploadForm.dramaId = null
+    uploadForm.watermarkDuration = null
+  }
+}
+
+// 文件选择
+const handleFileChange = (file) => {
+  uploadForm.file = file.raw
+}
+
+// 文件超出限制
+const handleExceed = () => {
+  ElMessage.warning('最多只能选择1个视频文件')
+}
+
+// 移除文件前
+const handleBeforeRemove = () => {
+  uploadForm.file = null
+  return true
+}
+
+// 开始上传
+const handleUpload = async () => {
+  if (!uploadFormRef.value) return
+
+  await uploadFormRef.value.validate(async (valid) => {
+    if (!valid) return
+
+    if (!uploadForm.file) {
+      ElMessage.warning('请选择视频文件')
+      return
+    }
+
+    // 校验水印
+    if (uploadForm.hasWatermark && !uploadForm.dramaId) {
+      ElMessage.warning('添加水印时必须选择短剧')
+      return
+    }
+
+    uploading.value = true
+    uploadProgress.value = 0
+    uploadStatus.value = ''
+
+    try {
+      // 构建FormData
+      const formData = new FormData()
+      formData.append('file', uploadForm.file)
+      formData.append('accountId', uploadForm.accountId)
+      formData.append('hasWatermark', uploadForm.hasWatermark)
+      if (uploadForm.dramaId) {
+        formData.append('dramaId', uploadForm.dramaId)
+      }
+      if (uploadForm.watermarkDuration) {
+        formData.append('watermarkDuration', uploadForm.watermarkDuration)
+      }
+      if (uploadForm.description) {
+        formData.append('description', uploadForm.description)
+      }
+
+      // 模拟进度
+      const progressInterval = setInterval(() => {
+        if (uploadProgress.value < 90) {
+          uploadProgress.value += 10
+        }
+      }, 500)
+
+      const res = await uploadVideo(formData)
+
+      clearInterval(progressInterval)
+      uploadProgress.value = 100
+      uploadStatus.value = 'success'
+
+      if (res.success) {
+        ElMessage.success('视频上传成功')
+        resetForm()
+        loadVideoList()
+      } else {
+        ElMessage.error(res.message || '视频上传失败')
+        uploadStatus.value = 'exception'
+      }
+    } catch (error) {
+      console.error('上传失败', error)
+      ElMessage.error('视频上传失败:' + (error.message || '未知错误'))
+      uploadStatus.value = 'exception'
+    } finally {
+      uploading.value = false
+      setTimeout(() => {
+        uploadProgress.value = 0
+        uploadStatus.value = ''
+      }, 2000)
+    }
+  })
+}
+
+// 重置表单
+const resetForm = () => {
+  uploadFormRef.value?.resetFields()
+  uploadRef.value?.clearFiles()
+  uploadForm.file = null
+  uploadForm.hasWatermark = false
+  uploadForm.dramaId = null
+  uploadForm.watermarkDuration = null
+  uploadForm.description = ''
+}
+
+// 加载视频列表
+const loadVideoList = async () => {
+  tableLoading.value = true
+  try {
+    const res = await getVideoList(
+      pagination.page,
+      pagination.size,
+      queryForm.accountId,
+      queryForm.uploadStatus
+    )
+    if (res.success && res.data) {
+      videoList.value = res.data.records || []
+      pagination.total = res.data.total || 0
+    }
+  } catch (error) {
+    console.error('加载视频列表失败', error)
+    ElMessage.error('加载视频列表失败')
+  } finally {
+    tableLoading.value = false
+  }
+}
+
+// 查询
+const handleQuery = () => {
+  pagination.page = 1
+  loadVideoList()
+}
+
+// 重置查询
+const handleResetQuery = () => {
+  queryForm.accountId = null
+  queryForm.uploadStatus = null
+  handleQuery()
+}
+
+// 分页大小变化
+const handleSizeChange = (val) => {
+  pagination.size = val
+  loadVideoList()
+}
+
+// 页码变化
+const handleCurrentChange = (val) => {
+  pagination.page = val
+  loadVideoList()
+}
+
+// 删除
+const handleDelete = (row) => {
+  ElMessageBox.confirm('确定要删除这条视频记录吗?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(async () => {
+    try {
+      const res = await deleteVideo(row.id)
+      if (res.success) {
+        ElMessage.success('删除成功')
+        loadVideoList()
+      } else {
+        ElMessage.error(res.message || '删除失败')
+      }
+    } catch (error) {
+      console.error('删除失败', error)
+      ElMessage.error('删除失败')
+    }
+  }).catch(() => {
+    // 取消删除
+  })
+}
+
+// 页面加载时获取数据
+onMounted(() => {
+  loadVideoList()
+})
+</script>
+
+<style scoped>
+/* 使用Tailwind CSS类替代自定义样式 */
+</style>

+ 38 - 0
portal/tailwind.config.js

@@ -0,0 +1,38 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+  content: [
+    "./index.html",
+    "./src/**/*.{vue,js,ts,jsx,tsx}",
+  ],
+  theme: {
+    extend: {
+      colors: {
+        primary: {
+          50: '#eff6ff',
+          100: '#dbeafe',
+          200: '#bfdbfe',
+          300: '#93c5fd',
+          400: '#60a5fa',
+          500: '#3b82f6',
+          600: '#2563eb',
+          700: '#1d4ed8',
+          800: '#1e40af',
+          900: '#1e3a8a',
+        },
+        secondary: {
+          50: '#f8fafc',
+          100: '#f1f5f9',
+          200: '#e2e8f0',
+          300: '#cbd5e1',
+          400: '#94a3b8',
+          500: '#64748b',
+          600: '#475569',
+          700: '#334155',
+          800: '#1e293b',
+          900: '#0f172a',
+        }
+      }
+    },
+  },
+  plugins: [],
+}

+ 2 - 1
portal/vite.config.js

@@ -15,7 +15,8 @@ export default defineConfig({
     proxy: {
       '/api': {
         target: 'http://localhost:8080',
-        changeOrigin: true
+        changeOrigin: true,
+        rewrite: (path) => path.replace(/^\/api/, '')
       }
     }
   }

+ 198 - 0
src/main/java/com/moka/gdtauto/common/Result.java

@@ -0,0 +1,198 @@
+package com.moka.gdtauto.common;
+
+import lombok.Data;
+import java.io.Serializable;
+
+/**
+ * 统一响应结果封装类
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Data
+public class Result<T> implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 是否成功
+     */
+    private Boolean success;
+
+    /**
+     * 响应消息
+     */
+    private String message;
+
+    /**
+     * 响应数据
+     */
+    private T data;
+
+    /**
+     * 总记录数(分页查询时使用)
+     */
+    private Long total;
+
+    /**
+     * 当前页码(分页查询时使用)
+     */
+    private Long page;
+
+    /**
+     * 每页数量(分页查询时使用)
+     */
+    private Long pageSize;
+
+    /**
+     * 总页数(分页查询时使用)
+     */
+    private Long totalPages;
+
+    /**
+     * 数量(统计时使用)
+     */
+    private Integer count;
+
+    /**
+     * 备注信息(额外提示)
+     */
+    private String note;
+
+    /**
+     * 使用说明(额外指引)
+     */
+    private String usage;
+
+    /**
+     * 私有构造方法
+     */
+    private Result() {
+    }
+
+    /**
+     * 成功响应(无数据)
+     * 
+     * @param message 成功消息
+     * @return Result实例
+     */
+    public static <T> Result<T> success(String message) {
+        Result<T> result = new Result<>();
+        result.setSuccess(true);
+        result.setMessage(message);
+        return result;
+    }
+
+    /**
+     * 成功响应(带数据)
+     * 
+     * @param data 响应数据
+     * @param message 成功消息
+     * @return Result实例
+     */
+    public static <T> Result<T> success(T data, String message) {
+        Result<T> result = new Result<>();
+        result.setSuccess(true);
+        result.setData(data);
+        result.setMessage(message);
+        return result;
+    }
+
+    /**
+     * 成功响应(仅数据,默认消息"操作成功")
+     * 
+     * @param data 响应数据
+     * @return Result实例
+     */
+    public static <T> Result<T> success(T data) {
+        return success(data, "操作成功");
+    }
+
+    /**
+     * 失败响应
+     * 
+     * @param message 失败消息
+     * @return Result实例
+     */
+    public static <T> Result<T> fail(String message) {
+        Result<T> result = new Result<>();
+        result.setSuccess(false);
+        result.setMessage(message);
+        return result;
+    }
+
+    /**
+     * 失败响应(带数据)
+     * 
+     * @param data 响应数据
+     * @param message 失败消息
+     * @return Result实例
+     */
+    public static <T> Result<T> fail(T data, String message) {
+        Result<T> result = new Result<>();
+        result.setSuccess(false);
+        result.setData(data);
+        result.setMessage(message);
+        return result;
+    }
+
+    /**
+     * 设置数量
+     * 
+     * @param count 数量
+     * @return Result实例
+     */
+    public Result<T> withCount(Integer count) {
+        this.count = count;
+        return this;
+    }
+
+    /**
+     * 设置备注
+     * 
+     * @param note 备注信息
+     * @return Result实例
+     */
+    public Result<T> withNote(String note) {
+        this.note = note;
+        return this;
+    }
+
+    /**
+     * 设置使用说明
+     * 
+     * @param usage 使用说明
+     * @return Result实例
+     */
+    public Result<T> withUsage(String usage) {
+        this.usage = usage;
+        return this;
+    }
+
+    /**
+     * 设置分页信息
+     * 
+     * @param page 当前页码
+     * @param pageSize 每页数量
+     * @param total 总记录数
+     * @return Result实例
+     */
+    public Result<T> withPage(Long page, Long pageSize, Long total) {
+        this.page = page;
+        this.pageSize = pageSize;
+        this.total = total;
+        this.totalPages = (total + pageSize - 1) / pageSize;
+        return this;
+    }
+
+    /**
+     * 设置总记录数(用于统计)
+     * 
+     * @param total 总记录数
+     * @return Result实例
+     */
+    public Result<T> withTotal(Long total) {
+        this.total = total;
+        return this;
+    }
+}

+ 132 - 0
src/main/java/com/moka/gdtauto/controller/TencentAccountController.java

@@ -0,0 +1,132 @@
+package com.moka.gdtauto.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.moka.gdtauto.common.Result;
+import com.moka.gdtauto.entity.TencentAccount;
+import com.moka.gdtauto.service.TencentAccountService;
+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.List;
+
+/**
+ * 腾讯广告账户控制器
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Tag(name = "广告账户管理", description = "腾讯广告账户信息同步与查询")
+@Slf4j
+@RestController
+@RequestMapping("/api/gdt/account")
+@RequiredArgsConstructor
+public class TencentAccountController {
+
+    private final TencentAccountService tencentAccountService;
+
+    /**
+     * 同步组织账户下的广告账户信息
+     * 
+     * @param orgAccountId 组织账户ID(tencent_ads_auth表的accountId)
+     * @return 同步结果
+     */
+    @Operation(
+        summary = "同步组织账户下的广告账户",
+        description = "根据组织账户ID,调用腾讯广告API获取该组织下所有广告账户信息并同步到数据库"
+    )
+    @PostMapping("/sync")
+    public Result<Void> syncAccounts(
+            @Parameter(description = "组织账户ID", required = true, example = "1234567890")
+            @RequestParam Long orgAccountId) {
+        try {
+            int count = tencentAccountService.syncAccountsByOrgAccountId(orgAccountId);
+            return Result.<Void>success("同步成功,共同步" + count + "个广告账户")
+                    .withCount(count);
+        } catch (Exception e) {
+            log.error("同步广告账户失败,orgAccountId={}", orgAccountId, e);
+            return Result.fail("同步失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 查询组织账户下的广告账户列表(分页)
+     * 
+     * @param orgAccountId 组织账户ID
+     * @param page 页码
+     * @param pageSize 每页数量
+     * @return 广告账户列表
+     */
+    @Operation(
+        summary = "查询组织账户下的广告账户列表",
+        description = "根据组织账户ID分页查询该组织下所有已同步的广告账户信息"
+    )
+    @GetMapping("/list")
+    public Result<List<TencentAccount>> listAccounts(
+            @Parameter(description = "组织账户ID", required = true, example = "1234567890")
+            @RequestParam Long orgAccountId,
+            @Parameter(description = "页码,从1开始", example = "1")
+            @RequestParam(defaultValue = "1") Long page,
+            @Parameter(description = "每页数量", example = "20")
+            @RequestParam(defaultValue = "20") Long pageSize) {
+        try {
+            // 计算偏移量
+            long offset = (page - 1) * pageSize;
+            
+            // 查询总数
+            LambdaQueryWrapper<TencentAccount> countWrapper = new LambdaQueryWrapper<>();
+            countWrapper.eq(TencentAccount::getOrgAccountId, orgAccountId)
+                       .eq(TencentAccount::getDeleted, 0);
+            long total = tencentAccountService.count(countWrapper);
+            
+            // 分页查询
+            LambdaQueryWrapper<TencentAccount> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(TencentAccount::getOrgAccountId, orgAccountId)
+                   .eq(TencentAccount::getDeleted, 0)
+                   .orderByDesc(TencentAccount::getUpdateTime)
+                   .last("LIMIT " + offset + ", " + pageSize);
+            
+            List<TencentAccount> accounts = tencentAccountService.list(wrapper);
+            
+            return Result.success(accounts, "查询成功")
+                    .withPage(page, pageSize, total);
+        } catch (Exception e) {
+            log.error("查询广告账户列表失败,orgAccountId={}", orgAccountId, e);
+            return Result.fail("查询失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 根据广告账户ID查询账户详情
+     * 
+     * @param accountId 广告账户ID
+     * @return 账户详情
+     */
+    @Operation(
+        summary = "查询广告账户详情",
+        description = "根据广告账户ID查询账户的详细信息"
+    )
+    @GetMapping("/detail")
+    public Result<TencentAccount> getAccountDetail(
+            @Parameter(description = "广告账户ID", required = true, example = "9876543210")
+            @RequestParam Long accountId) {
+        try {
+            LambdaQueryWrapper<TencentAccount> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(TencentAccount::getAccountId, accountId)
+                   .eq(TencentAccount::getDeleted, 0);
+            
+            TencentAccount account = tencentAccountService.getOne(wrapper);
+            if (account != null) {
+                return Result.success(account, "查询成功");
+            } else {
+                return Result.fail("未找到该广告账户信息");
+            }
+        } catch (Exception e) {
+            log.error("查询广告账户详情失败,accountId={}", accountId, e);
+            return Result.fail("查询失败:" + e.getMessage());
+        }
+    }
+}

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

@@ -1,5 +1,6 @@
 package com.moka.gdtauto.controller;
 
+import com.moka.gdtauto.common.Result;
 import com.moka.gdtauto.entity.TencentAdsAdgroup;
 import com.moka.gdtauto.service.TencentAdsAdgroupService;
 import io.swagger.v3.oas.annotations.Operation;
@@ -9,9 +10,7 @@ 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控制器
@@ -21,7 +20,7 @@ import java.util.Map;
  */
 @Slf4j
 @RestController
-@RequestMapping("/api/adgroups")
+@RequestMapping("/adgroups")
 @RequiredArgsConstructor
 @Tag(name = "广告组管理", description = "腾讯广告组数据同步与查询")
 public class TencentAdsAdgroupController {
@@ -33,7 +32,7 @@ public class TencentAdsAdgroupController {
      */
     @Operation(summary = "同步广告组数据", description = "从腾讯广告平台同步指定账户的广告组数据到数据库")
     @PostMapping("/sync")
-    public Map<String, Object> syncAdgroups(
+    public Result<Void> syncAdgroups(
             @Parameter(description = "账户ID", required = true, example = "66612379")
             @RequestParam Long accountId,
             @Parameter(description = "开始时间戳(秒)", example = "1708520382")
@@ -41,18 +40,14 @@ public class TencentAdsAdgroupController {
             @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 + "条广告组数据");
+            return Result.<Void>success("同步成功,共同步" + count + "条广告组数据")
+                    .withCount(count);
         } catch (Exception e) {
             log.error("同步广告组数据失败", e);
-            result.put("success", false);
-            result.put("message", "同步失败:" + e.getMessage());
+            return Result.fail("同步失败:" + e.getMessage());
         }
-        return result;
     }
 
     /**
@@ -60,7 +55,7 @@ public class TencentAdsAdgroupController {
      */
     @Operation(summary = "查询广告组列表", description = "分页查询广告组数据")
     @GetMapping("/list")
-    public Map<String, Object> listAdgroups(
+    public Result<List<TencentAdsAdgroup>> listAdgroups(
             @Parameter(description = "账户ID(可选)", example = "66612379")
             @RequestParam(required = false) Long accountId,
             @Parameter(description = "页码", example = "1")
@@ -68,21 +63,15 @@ public class TencentAdsAdgroupController {
             @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);
+            return Result.success(list, "查询成功")
+                    .withPage(page.longValue(), pageSize.longValue(), total);
         } catch (Exception e) {
             log.error("查询广告组列表失败", e);
-            result.put("success", false);
-            result.put("message", "查询失败:" + e.getMessage());
+            return Result.fail("查询失败:" + e.getMessage());
         }
-        return result;
     }
 }

+ 35 - 79
src/main/java/com/moka/gdtauto/controller/TencentAdsAuthController.java

@@ -1,5 +1,6 @@
 package com.moka.gdtauto.controller;
 
+import com.moka.gdtauto.common.Result;
 import com.moka.gdtauto.entity.SysUser;
 import com.moka.gdtauto.entity.TencentAdsAuth;
 import com.moka.gdtauto.service.TencentAdsAuthService;
@@ -10,11 +11,7 @@ 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;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestParam;
 
 
 /**
@@ -49,22 +46,17 @@ public class TencentAdsAuthController {
         description = "OAuth2.0授权流程第一步:生成腾讯广告平台的授权链接,引导用户到该链接完成授权"
     )
     @GetMapping("/authorize-url")
-    public Map<String, Object> getAuthorizationUrl(
+    public Result<String> getAuthorizationUrl(
             @Parameter(description = "验证请求有效性参数,值为用户自取,用于阻止跨站请求伪造攻击", example = "比如手机号码")
             @RequestParam(required = false, defaultValue = "state") String state) {
-        Map<String, Object> result = new HashMap<>();
         try {
             String authUrl = authService.getAuthorizationUrl(state);
-            result.put("success", true);
-            result.put("data", authUrl);
-            result.put("message", "获取授权URL成功");
-            result.put("usage", "请将用户引导到此URL进行授权");
+            return Result.success(authUrl, "获取授权URL成功")
+                    .withUsage("请将用户引导到此URL进行授权");
         } catch (Exception e) {
             log.error("获取授权URL失败", e);
-            result.put("success", false);
-            result.put("message", "获取授权URL失败:" + e.getMessage());
+            return Result.fail("获取授权URL失败:" + e.getMessage());
         }
-        return result;
     }
 
     /**
@@ -85,24 +77,19 @@ public class TencentAdsAuthController {
         description = "OAuth2.0授权流程第二步:接收腾讯广告平台的授权回调,使用authorization_code换取access_token和refresh_token,并保存到数据库"
     )
     @GetMapping("/callback")
-    public Map<String, Object> callback(
+    public Result<TencentAdsAuth> callback(
             @Parameter(description = "授权码,由腾讯广告平台回调时携带", required = true, example = "auth_code_example_123456")
             @RequestParam("authorization_code") String authorizationCode,
             @Parameter(description = "状态参数,用于校验请求合法性", example = "13099999999")
             @RequestParam(required = false) String state) {
-        Map<String, Object> result = new HashMap<>();
         try {
             TencentAdsAuth auth = authService.getAccessToken(authorizationCode, state);
-            result.put("success", true);
-            result.put("data", auth);
-            result.put("message", "授权成功,已保存token信息");
-            result.put("note", "token会自动维护,无需手动刷新");
+            return Result.success(auth, "授权成功,已保存token信息")
+                    .withNote("token会自动维护,无需手动刷新");
         } catch (Exception e) {
             log.error("授权失败", e);
-            result.put("success", false);
-            result.put("message", "授权失败:" + e.getMessage());
+            return Result.fail("授权失败:" + e.getMessage());
         }
-        return result;
     }
 
     @GetMapping("ok")
@@ -127,22 +114,17 @@ public class TencentAdsAuthController {
         description = "使用refresh_token刷新access_token。正常情况下,系统会通过定时任务自动刷新,此接口仅用于手动触发或紧急处理"
     )
     @PostMapping("/refresh")
-    public Map<String, Object> refreshToken(
+    public Result<TencentAdsAuth> refreshToken(
             @Parameter(description = "腾讯广告账户ID", required = true, example = "1234567890")
             @RequestParam Long accountId) {
-        Map<String, Object> result = new HashMap<>();
         try {
             TencentAdsAuth auth = authService.refreshAccessToken(accountId);
-            result.put("success", true);
-            result.put("data", auth);
-            result.put("message", "刷新令牌成功");
+            return Result.success(auth, "刷新令牌成功");
         } catch (Exception e) {
             log.error("刷新令牌失败", e);
-            result.put("success", false);
-            result.put("message", "刷新令牌失败:" + e.getMessage());
-            result.put("note", "如果refresh_token也过期,需要重新授权");
+            return Result.<TencentAdsAuth>fail("刷新令牌失败:" + e.getMessage())
+                    .withNote("如果refresh_token也过期,需要重新授权");
         }
-        return result;
     }
 
     /**
@@ -159,27 +141,21 @@ public class TencentAdsAuthController {
         description = "根据账户ID查询该账户的OAuth授权信息,包括access_token、refresh_token、过期时间等"
     )
     @GetMapping("/info")
-    public Map<String, Object> getAuthInfo(
+    public Result<TencentAdsAuth> getAuthInfo(
             @Parameter(description = "腾讯广告账户ID", required = true, example = "1234567890")
             @RequestParam Long accountId) {
-        Map<String, Object> result = new HashMap<>();
         try {
             TencentAdsAuth auth = authService.getByAccountId(accountId);
             if (auth != null) {
-                result.put("success", true);
-                result.put("data", auth);
-                result.put("message", "查询成功");
+                return Result.success(auth, "查询成功");
             } else {
-                result.put("success", false);
-                result.put("message", "未找到授权信息");
-                result.put("note", "请先进行授权");
+                return Result.<TencentAdsAuth>fail("未找到授权信息")
+                        .withNote("请先进行授权");
             }
         } catch (Exception e) {
             log.error("查询授权信息失败", e);
-            result.put("success", false);
-            result.put("message", "查询失败:" + e.getMessage());
+            return Result.fail("查询失败:" + e.getMessage());
         }
-        return result;
     }
 
     /**
@@ -195,20 +171,15 @@ public class TencentAdsAuthController {
         description = "获取所有已授权且处于有效状态的账户列表,用于管理和监控已授权账户"
     )
     @GetMapping("/list")
-    public Map<String, Object> listAuths() {
-        Map<String, Object> result = new HashMap<>();
+    public Result<List<TencentAdsAuth>> listAuths() {
         try {
             List<TencentAdsAuth> auths = authService.listValidAuths();
-            result.put("success", true);
-            result.put("data", auths);
-            result.put("count", auths.size());
-            result.put("message", "查询成功");
+            return Result.success(auths, "查询成功")
+                    .withCount(auths.size());
         } catch (Exception e) {
             log.error("查询授权列表失败", e);
-            result.put("success", false);
-            result.put("message", "查询失败:" + e.getMessage());
+            return Result.fail("查询失败:" + e.getMessage());
         }
-        return result;
     }
 
     /**
@@ -224,24 +195,19 @@ public class TencentAdsAuthController {
         description = "生成腾讯广告平台的实名认证链接,引导用户微信扫码完成实名认证,获取user_token用于调用受限接口"
     )
     @GetMapping("/user-token-url")
-    public Map<String, Object> getUserTokenUrl(
+    public Result<String> getUserTokenUrl(
             @Parameter(description = "手机号码,用于确定唯一性", example = "13099999999")
             @RequestParam(required = true, defaultValue = "") String phone) {
-        Map<String, Object> result = new HashMap<>();
         try {
             String state = phone;
             String userTokenUrl = authService.getUserTokenUrl(state);
-            result.put("success", true);
-            result.put("data", userTokenUrl);
-            result.put("message", "获取实名认证URL成功");
-            result.put("usage", "请将用户引导到此URL进行微信扫码实名认证");
-            result.put("note", "用户必须在服务商系统或客户工作台完成实名认证+组织认证");
+            return Result.success(userTokenUrl, "获取实名认证URL成功")
+                    .withUsage("请将用户引导到此URL进行微信扫码实名认证")
+                    .withNote("用户必须在服务商系统或客户工作台完成实名认证+组织认证");
         } catch (Exception e) {
             log.error("获取实名认证URL失败", e);
-            result.put("success", false);
-            result.put("message", "获取实名认证URL失败:" + e.getMessage());
+            return Result.fail("获取实名认证URL失败:" + e.getMessage());
         }
-        return result;
     }
     
     /**
@@ -259,7 +225,7 @@ public class TencentAdsAuthController {
         description = "接收腾讯广告平台实名认证组件的回调,通过state参数获取手机号,保存user_token到用户表"
     )
     @GetMapping("/user-token-callback")
-    public Map<String, Object> userTokenCallback(
+    public Result<SysUser> userTokenCallback(
             @Parameter(description = "用户认证状态: 0-未找到账号, 1-未完成实名认证, 2-已完成实名认证", required = true, example = "2")
             @RequestParam("user_status") Integer userStatus,
             @Parameter(description = "实名认证令牌", example = "abcdefghijklmn")
@@ -268,21 +234,16 @@ public class TencentAdsAuthController {
             @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", "实名认证令牌保存成功");
+            return Result.success(user, "实名认证令牌保存成功");
         } catch (Exception e) {
             log.error("保存实名认证令牌失败", e);
-            result.put("success", false);
-            result.put("message", "保存失败:" + e.getMessage());
+            return Result.fail("保存失败:" + e.getMessage());
         }
-        return result;
     }
     
     /**
@@ -298,20 +259,15 @@ public class TencentAdsAuthController {
         description = "查询即将过期(30分钟内)的access_token列表,用于监控和预警。系统定时任务会自动刷新这些token"
     )
     @GetMapping("/expiring")
-    public Map<String, Object> listExpiringAuths() {
-        Map<String, Object> result = new HashMap<>();
+    public Result<List<TencentAdsAuth>> listExpiringAuths() {
         try {
             List<TencentAdsAuth> auths = authService.listExpiringAuths();
-            result.put("success", true);
-            result.put("data", auths);
-            result.put("count", auths.size());
-            result.put("message", "查询成功");
-            result.put("note", "定时任务会自动刷新这些token");
+            return Result.success(auths, "查询成功")
+                    .withCount(auths.size())
+                    .withNote("定时任务会自动刷新这些token");
         } catch (Exception e) {
             log.error("查询即将过期token失败", e);
-            result.put("success", false);
-            result.put("message", "查询失败:" + e.getMessage());
+            return Result.fail("查询失败:" + e.getMessage());
         }
-        return result;
     }
 }

+ 134 - 0
src/main/java/com/moka/gdtauto/controller/TencentAssetDramaController.java

@@ -0,0 +1,134 @@
+package com.moka.gdtauto.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.moka.gdtauto.common.Result;
+import com.moka.gdtauto.entity.TencentAssetDrama;
+import com.moka.gdtauto.service.TencentAssetDramaService;
+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.List;
+
+/**
+ * 腾讯广告短剧资产控制器
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Tag(name = "短剧资产管理", description = "腾讯广告短剧资产查询")
+@Slf4j
+@RestController
+@RequestMapping("/api/gdt/asset/drama")
+@RequiredArgsConstructor
+public class TencentAssetDramaController {
+
+    private final TencentAssetDramaService assetDramaService;
+
+    /**
+     * 同步短剧资产到数据库
+     * 
+     * @param orgAccountId 组织账户ID
+     * @param phone 手机号
+     * @param accountId 推广账号ID
+     * @return 同步结果
+     */
+    @Operation(
+        summary = "同步短剧资产到数据库",
+        description = "从腾讯广告API获取指定账户的短剧资产并同步到数据库"
+    )
+    @PostMapping("/sync")
+    public Result<Void> syncAssets(
+            @Parameter(description = "组织账户ID", required = true, example = "33453856")
+            @RequestParam Long orgAccountId,
+            @Parameter(description = "手机号", required = true, example = "13057411858")
+            @RequestParam String phone,
+            @Parameter(description = "推广账号ID", required = true, example = "69781948")
+            @RequestParam Long accountId) {
+        try {
+            int count = assetDramaService.syncMarketingAssets(orgAccountId, phone, accountId);
+            return Result.<Void>success("同步成功,共同步" + count + "个短剧资产")
+                    .withCount(count);
+        } catch (Exception e) {
+            log.error("同步短剧资产失败,orgAccountId={}, phone={}, accountId={}", 
+                orgAccountId, phone, accountId, e);
+            return Result.fail("同步失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 分页查询短剧资产列表
+     * 
+     * @param page 页码
+     * @param pageSize 每页数量
+     * @return 短剧资产列表
+     */
+    @Operation(
+        summary = "分页查询短剧资产列表",
+        description = "从数据库分页查询短剧资产信息"
+    )
+    @GetMapping("/list")
+    public Result<List<TencentAssetDrama>> listAssets(
+            @Parameter(description = "页码,从1开始", example = "1")
+            @RequestParam(defaultValue = "1") Long page,
+            @Parameter(description = "每页数量", example = "20")
+            @RequestParam(defaultValue = "20") Long pageSize) {
+        try {
+            // 计算偏移量
+            long offset = (page - 1) * pageSize;
+            
+            // 查询总数
+            LambdaQueryWrapper<TencentAssetDrama> countWrapper = new LambdaQueryWrapper<>();
+            countWrapper.eq(TencentAssetDrama::getIsDeleted, 0);
+            long total = assetDramaService.count(countWrapper);
+            
+            // 分页查询
+            LambdaQueryWrapper<TencentAssetDrama> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(TencentAssetDrama::getIsDeleted, 0)
+                   .orderByDesc(TencentAssetDrama::getUpdateTime)
+                   .last("LIMIT " + offset + ", " + pageSize);
+            
+            List<TencentAssetDrama> assetList = assetDramaService.list(wrapper);
+            
+            return Result.success(assetList, "查询成功")
+                    .withPage(page, pageSize, total);
+        } catch (Exception e) {
+            log.error("查询短剧资产列表失败", e);
+            return Result.fail("查询失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 根据短剧资产ID查询详情
+     * 
+     * @param id 短剧资产ID
+     * @return 短剧资产详情
+     */
+    @Operation(
+        summary = "查询短剧资产详情",
+        description = "根据ID查询短剧资产的详细信息"
+    )
+    @GetMapping("/detail")
+    public Result<TencentAssetDrama> getAssetDetail(
+            @Parameter(description = "短剧资产ID", required = true)
+            @RequestParam Long id) {
+        try {
+            LambdaQueryWrapper<TencentAssetDrama> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(TencentAssetDrama::getMarketingAssetId, id)
+                   .eq(TencentAssetDrama::getIsDeleted, 0);
+            
+            TencentAssetDrama asset = assetDramaService.getOne(wrapper);
+            if (asset != null) {
+                return Result.success(asset, "查询成功");
+            } else {
+                return Result.fail("未找到该短剧资产信息");
+            }
+        } catch (Exception e) {
+            log.error("查询短剧资产详情失败,id={}", id, e);
+            return Result.fail("查询失败:" + e.getMessage());
+        }
+    }
+}

+ 126 - 0
src/main/java/com/moka/gdtauto/controller/TencentConversionController.java

@@ -0,0 +1,126 @@
+package com.moka.gdtauto.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.moka.gdtauto.common.Result;
+import com.moka.gdtauto.entity.TencentConversion;
+import com.moka.gdtauto.service.TencentConversionService;
+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.List;
+
+/**
+ * 腾讯广告转化归因控制器
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Tag(name = "转化归因管理", description = "腾讯广告转化归因信息同步与查询")
+@Slf4j
+@RestController
+@RequestMapping("/api/gdt/conversion")
+@RequiredArgsConstructor
+public class TencentConversionController {
+
+    private final TencentConversionService tencentConversionService;
+
+    /**
+     * 同步转化归因数据
+     * 
+     * @param orgAccountId 组织账户ID
+     * @param accountId 广告账户ID
+     * @return 同步结果
+     */
+    @Operation(
+        summary = "同步转化归因数据",
+        description = "根据组织账户ID和广告账户ID,调用腾讯广告API获取该账户下符合条件的转化归因并同步到数据库(短剧用户增长注册变现)"
+    )
+    @PostMapping("/sync")
+    public Result<Void> syncConversions(
+            @Parameter(description = "组织账户ID", required = true, example = "33453856")
+            @RequestParam Long orgAccountId,
+            @Parameter(description = "广告账户ID", required = true, example = "69781948")
+            @RequestParam Long accountId) {
+        try {
+            int count = tencentConversionService.syncConversions(orgAccountId, accountId);
+            return Result.<Void>success("同步成功,共同步" + count + "条转化归因")
+                    .withCount(count);
+        } catch (Exception e) {
+            log.error("同步转化归因失败,orgAccountId={}, accountId={}", orgAccountId, accountId, e);
+            return Result.fail("同步失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 查询转化归因列表(分页)
+     * 
+     * @param page 页码
+     * @param pageSize 每页数量
+     * @return 转化归因列表
+     */
+    @Operation(
+        summary = "查询转化归因列表",
+        description = "分页查询已同步的转化归因信息"
+    )
+    @GetMapping("/list")
+    public Result<List<TencentConversion>> listConversions(
+            @Parameter(description = "页码,从1开始", example = "1")
+            @RequestParam(defaultValue = "1") Long page,
+            @Parameter(description = "每页数量", example = "20")
+            @RequestParam(defaultValue = "20") Long pageSize) {
+        try {
+            // 计算偏移量
+            long offset = (page - 1) * pageSize;
+            
+            // 查询总数
+            long total = tencentConversionService.count();
+            
+            // 分页查询
+            LambdaQueryWrapper<TencentConversion> wrapper = new LambdaQueryWrapper<>();
+            wrapper.orderByDesc(TencentConversion::getUpdateTime)
+                   .last("LIMIT " + offset + ", " + pageSize);
+            
+            List<TencentConversion> conversions = tencentConversionService.list(wrapper);
+            
+            return Result.success(conversions, "查询成功")
+                    .withPage(page, pageSize, total);
+        } catch (Exception e) {
+            log.error("查询转化归因列表失败", e);
+            return Result.fail("查询失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 根据转化ID查询转化详情
+     * 
+     * @param conversionId 转化ID
+     * @return 转化详情
+     */
+    @Operation(
+        summary = "查询转化详情",
+        description = "根据转化ID查询转化的详细信息"
+    )
+    @GetMapping("/detail")
+    public Result<TencentConversion> getConversionDetail(
+            @Parameter(description = "转化ID", required = true, example = "72627668")
+            @RequestParam Long conversionId) {
+        try {
+            LambdaQueryWrapper<TencentConversion> wrapper = new LambdaQueryWrapper<>();
+            wrapper.eq(TencentConversion::getConversionId, conversionId);
+            
+            TencentConversion conversion = tencentConversionService.getOne(wrapper);
+            if (conversion != null) {
+                return Result.success(conversion, "查询成功");
+            } else {
+                return Result.fail("未找到该转化归因信息");
+            }
+        } catch (Exception e) {
+            log.error("查询转化详情失败,conversionId={}", conversionId, e);
+            return Result.fail("查询失败:" + e.getMessage());
+        }
+    }
+}

+ 157 - 0
src/main/java/com/moka/gdtauto/controller/TencentImagesController.java

@@ -0,0 +1,157 @@
+package com.moka.gdtauto.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.moka.gdtauto.common.Result;
+import com.moka.gdtauto.entity.TencentImages;
+import com.moka.gdtauto.service.TencentImagesService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 腾讯广告图片上传Controller
+ *
+ * @author moka
+ * @since 2026-02-06
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/tencent-images")
+@Api(tags = "腾讯广告图片管理")
+public class TencentImagesController {
+
+    @Autowired
+    private TencentImagesService imagesService;
+
+    /**
+     * 上传图片到腾讯广告
+     */
+    @PostMapping("/upload")
+    @ApiOperation("上传图片到腾讯广告")
+    public Result<TencentImages> uploadImage(
+            @ApiParam("图片文件") @RequestParam("file") MultipartFile file,
+            @ApiParam("广告账户ID") @RequestParam("accountId") Long accountId,
+            @ApiParam("是否添加水印") @RequestParam(value = "hasWatermark", required = false, defaultValue = "false") Boolean hasWatermark,
+            @ApiParam("剧ID") @RequestParam(value = "dramaId", required = false) Long dramaId,
+            @ApiParam("图片描述") @RequestParam(value = "description", required = false) String description) {
+        
+        try {
+            log.info("开始上传图片: 文件名={}, 账户ID={}, 是否添加水印={}, 剧ID={}", 
+                file.getOriginalFilename(), accountId, hasWatermark, dramaId);
+
+            // 校验文件
+            if (file.isEmpty()) {
+                return Result.fail("图片文件不能为空");
+            }
+
+            // 校验文件格式
+            String filename = file.getOriginalFilename();
+            if (filename == null || (!filename.toLowerCase().endsWith(".jpg") 
+                && !filename.toLowerCase().endsWith(".jpeg") 
+                && !filename.toLowerCase().endsWith(".png"))) {
+                return Result.fail("仅支持JPG、JPEG、PNG格式的图片");
+            }
+
+            // 校验文件大小(最大10MB)
+            if (file.getSize() > 10 * 1024 * 1024) {
+                return Result.fail("图片大小不能超过10MB");
+            }
+
+            // 如果选择添加水印,必须选择剧
+            if (hasWatermark && dramaId == null) {
+                return Result.fail("添加水印时必须选择剧");
+            }
+
+            // 上传图片
+            TencentImages result = imagesService.uploadImage(file, accountId, hasWatermark, dramaId, description);
+            
+            if ("SUCCESS".equals(result.getUploadStatus())) {
+                log.info("图片上传成功,ID: {}", result.getId());
+                return Result.success(result, "图片上传成功");
+            } else {
+                log.error("图片上传失败: {}", result.getErrorMessage());
+                return Result.fail("图片上传失败: " + result.getErrorMessage());
+            }
+            
+        } catch (Exception e) {
+            log.error("上传图片异常", e);
+            return Result.fail("上传图片异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 分页查询图片列表
+     */
+    @GetMapping("/list")
+    @ApiOperation("分页查询图片列表")
+    public Result<Page<TencentImages>> list(
+            @ApiParam("页码") @RequestParam(defaultValue = "1") Long page,
+            @ApiParam("每页数量") @RequestParam(defaultValue = "10") Long size,
+            @ApiParam("账户ID") @RequestParam(required = false) Long accountId,
+            @ApiParam("上传状态") @RequestParam(required = false) String uploadStatus) {
+        
+        try {
+            Page<TencentImages> pageParam = new Page<>(page, size);
+            LambdaQueryWrapper<TencentImages> wrapper = new LambdaQueryWrapper<>();
+            
+            if (accountId != null) {
+                wrapper.eq(TencentImages::getAccountId, accountId);
+            }
+            if (uploadStatus != null && !uploadStatus.trim().isEmpty()) {
+                wrapper.eq(TencentImages::getUploadStatus, uploadStatus);
+            }
+            
+            wrapper.orderByDesc(TencentImages::getCreatedTime);
+            
+            Page<TencentImages> result = imagesService.page(pageParam, wrapper);
+            return Result.success(result);
+            
+        } catch (Exception e) {
+            log.error("查询图片列表异常", e);
+            return Result.fail("查询图片列表异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 根据ID查询图片详情
+     */
+    @GetMapping("/{id}")
+    @ApiOperation("根据ID查询图片详情")
+    public Result<TencentImages> getById(@ApiParam("图片ID") @PathVariable Long id) {
+        try {
+            TencentImages image = imagesService.getById(id);
+            if (image == null) {
+                return Result.fail("图片不存在");
+            }
+            return Result.success(image);
+        } catch (Exception e) {
+            log.error("查询图片详情异常", e);
+            return Result.fail("查询图片详情异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 删除图片记录
+     */
+    @DeleteMapping("/{id}")
+    @ApiOperation("删除图片记录")
+    public Result<Void> delete(@ApiParam("图片ID") @PathVariable Long id) {
+        try {
+            boolean success = imagesService.removeById(id);
+            if (success) {
+                log.info("删除图片记录成功,ID: {}", id);
+                return Result.success(null, "删除成功");
+            } else {
+                return Result.fail("删除失败");
+            }
+        } catch (Exception e) {
+            log.error("删除图片记录异常", e);
+            return Result.fail("删除图片记录异常: " + e.getMessage());
+        }
+    }
+}

+ 163 - 0
src/main/java/com/moka/gdtauto/controller/TencentVideoController.java

@@ -0,0 +1,163 @@
+package com.moka.gdtauto.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.moka.gdtauto.common.Result;
+import com.moka.gdtauto.entity.TencentVideo;
+import com.moka.gdtauto.service.TencentVideoService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 腾讯广告视频上传Controller
+ *
+ * @author moka
+ * @since 2026-02-06
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/tencent-video")
+@Api(tags = "腾讯广告视频管理")
+public class TencentVideoController {
+
+    @Autowired
+    private TencentVideoService videoService;
+
+    /**
+     * 上传视频到腾讯广告
+     */
+    @PostMapping("/upload")
+    @ApiOperation("上传视频到腾讯广告")
+    public Result<TencentVideo> uploadVideo(
+            @ApiParam("视频文件") @RequestParam("file") MultipartFile file,
+            @ApiParam("广告账户ID") @RequestParam("accountId") Long accountId,
+            @ApiParam("是否添加水印") @RequestParam(value = "hasWatermark", required = false, defaultValue = "false") Boolean hasWatermark,
+            @ApiParam("剧ID") @RequestParam(value = "dramaId", required = false) Long dramaId,
+            @ApiParam("水印时长(秒)") @RequestParam(value = "watermarkDuration", required = false) Integer watermarkDuration,
+            @ApiParam("视频描述") @RequestParam(value = "description", required = false) String description) {
+
+        try {
+            log.info("开始上传视频: 文件名={}, 账户ID={}, 是否添加水印={}, 剧ID={}, 水印时长={}",
+                    file.getOriginalFilename(), accountId, hasWatermark, dramaId, watermarkDuration);
+
+            // 校验文件
+            if (file.isEmpty()) {
+                return Result.fail("视频文件不能为空");
+            }
+
+            // 校验文件格式
+            String filename = file.getOriginalFilename();
+            if (filename == null || (!filename.toLowerCase().endsWith(".mp4")
+                    && !filename.toLowerCase().endsWith(".mov")
+                    && !filename.toLowerCase().endsWith(".avi"))) {
+                return Result.fail("仅支持MP4、MOV、AVI格式的视频");
+            }
+
+            // 校验文件大小(最大100MB)
+            if (file.getSize() > 100 * 1024 * 1024) {
+                return Result.fail("视频大小不能超过100MB");
+            }
+
+            // 如果选择添加水印,必须选择剧
+            if (hasWatermark && dramaId == null) {
+                return Result.fail("添加水印时必须选择剧");
+            }
+
+            // 校验水印时长
+            if (watermarkDuration != null && watermarkDuration <= 0) {
+                return Result.fail("水印时长必须大于0秒");
+            }
+
+            // 上传视频
+            TencentVideo result = videoService.uploadVideo(file, accountId, hasWatermark, dramaId, watermarkDuration, description);
+
+            if ("SUCCESS".equals(result.getUploadStatus())) {
+                log.info("视频上传成功,ID: {}", result.getId());
+                return Result.success(result, "视频上传成功");
+            } else {
+                log.error("视频上传失败: {}", result.getErrorMessage());
+                return Result.fail("视频上传失败: " + result.getErrorMessage());
+            }
+
+        } catch (Exception e) {
+            log.error("上传视频异常", e);
+            return Result.fail("上传视频异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 分页查询视频列表
+     */
+    @GetMapping("/list")
+    @ApiOperation("分页查询视频列表")
+    public Result<Page<TencentVideo>> list(
+            @ApiParam("页码") @RequestParam(defaultValue = "1") Long page,
+            @ApiParam("每页数量") @RequestParam(defaultValue = "10") Long size,
+            @ApiParam("账户ID") @RequestParam(required = false) Long accountId,
+            @ApiParam("上传状态") @RequestParam(required = false) String uploadStatus) {
+
+        try {
+            Page<TencentVideo> pageParam = new Page<>(page, size);
+            LambdaQueryWrapper<TencentVideo> wrapper = new LambdaQueryWrapper<>();
+
+            if (accountId != null) {
+                wrapper.eq(TencentVideo::getAccountId, accountId);
+            }
+            if (uploadStatus != null && !uploadStatus.trim().isEmpty()) {
+                wrapper.eq(TencentVideo::getUploadStatus, uploadStatus);
+            }
+
+            wrapper.orderByDesc(TencentVideo::getCreatedTime);
+
+            Page<TencentVideo> result = videoService.page(pageParam, wrapper);
+            return Result.success(result);
+
+        } catch (Exception e) {
+            log.error("查询视频列表异常", e);
+            return Result.fail("查询视频列表异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 根据ID查询视频详情
+     */
+    @GetMapping("/{id}")
+    @ApiOperation("根据ID查询视频详情")
+    public Result<TencentVideo> getById(@ApiParam("视频ID") @PathVariable Long id) {
+        try {
+            TencentVideo video = videoService.getById(id);
+            if (video == null) {
+                return Result.fail("视频不存在");
+            }
+            return Result.success(video);
+        } catch (Exception e) {
+            log.error("查询视频详情异常", e);
+            return Result.fail("查询视频详情异常: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 删除视频记录
+     */
+    @DeleteMapping("/{id}")
+    @ApiOperation("删除视频记录")
+    public Result<Void> delete(@ApiParam("视频ID") @PathVariable Long id) {
+        try {
+            boolean success = videoService.removeById(id);
+            if (success) {
+                log.info("删除视频记录成功,ID: {}", id);
+                return Result.success(null, "删除成功");
+            } else {
+                return Result.fail("删除失败");
+            }
+        } catch (Exception e) {
+            log.error("删除视频记录异常", e);
+            return Result.fail("删除视频记录异常: " + e.getMessage());
+        }
+    }
+}

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

@@ -0,0 +1,85 @@
+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-06
+ */
+@Data
+@TableName("tencent_account")
+public class TencentAccount {
+
+    /**
+     * 主键ID
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 组织账户ID,来自tencent_ads_auth表的accountId
+     */
+    private Long orgAccountId;
+
+    /**
+     * 广告账户ID
+     */
+    private Long accountId;
+
+    /**
+     * 企业名称
+     */
+    private String corporationName;
+
+    /**
+     * 是否竞价广告账户,1:是 0:否
+     */
+    private Boolean isBid;
+
+    /**
+     * 是否微信MP广告账户,1:是 0:否
+     */
+    private Boolean isMp;
+
+    /**
+     * 废弃字段,1:是 0:否
+     */
+    private Boolean isAdx;
+
+    /**
+     * 广告主备注列表,JSON字符串格式
+     */
+    private String commentList;
+
+    /**
+     * 备注列表第一个元素的用户ID
+     */
+    private Long commentUserId;
+
+    /**
+     * 备注列表第一个元素的备注内容
+     */
+    private String comment;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+
+    /**
+     * 逻辑删除标识:0-未删除,1-已删除
+     */
+    private Integer deleted;
+}

+ 65 - 0
src/main/java/com/moka/gdtauto/entity/TencentAssetDrama.java

@@ -0,0 +1,65 @@
+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-06
+ */
+@Data
+@TableName("tencent_asset_drama")
+public class TencentAssetDrama {
+
+    /**
+     * 主键ID
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 产品ID
+     */
+    private Long marketingAssetId;
+
+    /**
+     * 产品名称
+     */
+    private String marketingAssetName;
+
+    /**
+     * 产品类型
+     */
+    private String marketingAssetType;
+
+    /**
+     * 创建时间,时间戳
+     */
+    private Long createdTime;
+
+    /**
+     * 是否删除,0:不是,1:是
+     */
+    private Integer isDeleted;
+
+    /**
+     * 资产属性列表JSON
+     */
+    private String properties;
+
+    /**
+     * 记录创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 记录更新时间
+     */
+    private LocalDateTime updateTime;
+}

+ 45 - 0
src/main/java/com/moka/gdtauto/entity/TencentConversion.java

@@ -0,0 +1,45 @@
+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-06
+ */
+@Data
+@TableName("tencent_conversion")
+public class TencentConversion {
+
+    /**
+     * 主键ID
+     */
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 转化ID
+     */
+    private Long conversionId;
+
+    /**
+     * 转化名称
+     */
+    private String conversionName;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updateTime;
+}

+ 110 - 0
src/main/java/com/moka/gdtauto/entity/TencentImages.java

@@ -0,0 +1,110 @@
+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-06
+ */
+@Data
+@TableName("tencent_images")
+public class TencentImages {
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 广告账户ID
+     */
+    private Long accountId;
+
+    /**
+     * 腾讯返回的图片ID
+     */
+    private String imageId;
+
+    /**
+     * 图片宽度(px)
+     */
+    private Integer imageWidth;
+
+    /**
+     * 图片高度(px)
+     */
+    private Integer imageHeight;
+
+    /**
+     * 图片大小(B)
+     */
+    private Long imageFileSize;
+
+    /**
+     * 图片类型
+     */
+    private String imageType;
+
+    /**
+     * 图片预览地址
+     */
+    private String previewUrl;
+
+    /**
+     * 原始文件名
+     */
+    private String originalFilename;
+
+    /**
+     * 是否添加水印(0:否 1:是)
+     */
+    private Boolean hasWatermark;
+
+    /**
+     * 水印文字(剧名)
+     */
+    private String watermarkText;
+
+    /**
+     * 关联的剧ID
+     */
+    private Long dramaId;
+
+    /**
+     * 图片描述
+     */
+    private String description;
+
+    /**
+     * 上传状态(SUCCESS:成功 FAILED:失败)
+     */
+    private String uploadStatus;
+
+    /**
+     * 错误信息
+     */
+    private String errorMessage;
+
+    /**
+     * 创建人
+     */
+    private String createdBy;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createdTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updatedTime;
+}

+ 115 - 0
src/main/java/com/moka/gdtauto/entity/TencentVideo.java

@@ -0,0 +1,115 @@
+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-06
+ */
+@Data
+@TableName("tencent_video")
+public class TencentVideo {
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 广告账户ID
+     */
+    private Long accountId;
+
+    /**
+     * 腾讯返回的视频ID
+     */
+    private String videoId;
+
+    /**
+     * 腾讯返回的封面图片ID
+     */
+    private String coverImageId;
+
+    /**
+     * 视频宽度(px)
+     */
+    private Integer videoWidth;
+
+    /**
+     * 视频高度(px)
+     */
+    private Integer videoHeight;
+
+    /**
+     * 视频大小(B)
+     */
+    private Long videoFileSize;
+
+    /**
+     * 视频时长(秒)
+     */
+    private Integer videoDuration;
+
+    /**
+     * 原始文件名
+     */
+    private String originalFilename;
+
+    /**
+     * 是否添加水印(0:否 1:是)
+     */
+    private Boolean hasWatermark;
+
+    /**
+     * 水印文字(剧名)
+     */
+    private String watermarkText;
+
+    /**
+     * 水印时长(秒),为空表示全程
+     */
+    private Integer watermarkDuration;
+
+    /**
+     * 关联的剧ID
+     */
+    private Long dramaId;
+
+    /**
+     * 视频描述
+     */
+    private String description;
+
+    /**
+     * 上传状态(SUCCESS:成功 FAILED:失败)
+     */
+    private String uploadStatus;
+
+    /**
+     * 错误信息
+     */
+    private String errorMessage;
+
+    /**
+     * 创建人
+     */
+    private String createdBy;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createdTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updatedTime;
+}

+ 27 - 0
src/main/java/com/moka/gdtauto/mapper/TencentAccountMapper.java

@@ -0,0 +1,27 @@
+package com.moka.gdtauto.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.moka.gdtauto.entity.TencentAccount;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 腾讯广告账户Mapper接口
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Mapper
+public interface TencentAccountMapper extends BaseMapper<TencentAccount> {
+
+    /**
+     * 批量插入或更新账户信息
+     * 使用 INSERT INTO ... ON DUPLICATE KEY UPDATE
+     * 
+     * @param accounts 账户列表
+     * @return 影响行数
+     */
+    int batchInsertOrUpdate(@Param("accounts") List<TencentAccount> accounts);
+}

+ 27 - 0
src/main/java/com/moka/gdtauto/mapper/TencentAssetDramaMapper.java

@@ -0,0 +1,27 @@
+package com.moka.gdtauto.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.moka.gdtauto.entity.TencentAssetDrama;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 腾讯广告短剧资产Mapper接口
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Mapper
+public interface TencentAssetDramaMapper extends BaseMapper<TencentAssetDrama> {
+
+    /**
+     * 批量插入或更新短剧资产信息
+     * 使用 INSERT INTO ... ON DUPLICATE KEY UPDATE
+     * 
+     * @param dramas 短剧资产列表
+     * @return 影响行数
+     */
+    int batchInsertOrUpdate(@Param("dramas") List<TencentAssetDrama> dramas);
+}

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

@@ -0,0 +1,15 @@
+package com.moka.gdtauto.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.moka.gdtauto.entity.TencentConversion;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 腾讯广告转化归因Mapper接口
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Mapper
+public interface TencentConversionMapper extends BaseMapper<TencentConversion> {
+}

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

@@ -0,0 +1,15 @@
+package com.moka.gdtauto.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.moka.gdtauto.entity.TencentImages;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 腾讯广告图片Mapper接口
+ *
+ * @author moka
+ * @since 2026-02-06
+ */
+@Mapper
+public interface TencentImagesMapper extends BaseMapper<TencentImages> {
+}

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

@@ -0,0 +1,15 @@
+package com.moka.gdtauto.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.moka.gdtauto.entity.TencentVideo;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 腾讯广告视频Mapper接口
+ *
+ * @author moka
+ * @since 2026-02-06
+ */
+@Mapper
+public interface TencentVideoMapper extends BaseMapper<TencentVideo> {
+}

+ 31 - 0
src/main/java/com/moka/gdtauto/service/TencentAccountService.java

@@ -0,0 +1,31 @@
+package com.moka.gdtauto.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.moka.gdtauto.entity.TencentAccount;
+
+import java.util.List;
+
+/**
+ * 腾讯广告账户服务接口
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+public interface TencentAccountService extends IService<TencentAccount> {
+
+    /**
+     * 同步组织账户下的广告账户信息
+     * 
+     * @param orgAccountId 组织账户ID
+     * @return 同步的账户数量
+     */
+    int syncAccountsByOrgAccountId(Long orgAccountId);
+
+    /**
+     * 批量插入或更新账户信息
+     * 
+     * @param accounts 账户列表
+     * @return 影响行数
+     */
+    int batchInsertOrUpdate(List<TencentAccount> accounts);
+}

+ 175 - 0
src/main/java/com/moka/gdtauto/service/TencentAssetDramaService.java

@@ -0,0 +1,175 @@
+package com.moka.gdtauto.service;
+
+import com.alibaba.fastjson2.JSON;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.moka.gdtauto.entity.TencentAssetDrama;
+import com.moka.gdtauto.mapper.TencentAssetDramaMapper;
+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.MarketingTargetAssetsGetResponseData;
+import com.tencent.ads.model.v3.MarketingTargetType;
+import com.tencent.ads.v3.TencentAds;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 腾讯广告短剧资产服务
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TencentAssetDramaService extends ServiceImpl<TencentAssetDramaMapper, TencentAssetDrama> {
+
+    private final TencentAssetDramaMapper tencentAssetDramaMapper;
+    private final TencentAdsApiClientFactory clientFactory;
+
+    /**
+     * 获取短剧资产列表
+     * 
+     * @param orgAccountId 组织账户ID
+     * @param phone 手机号
+     * @param accountId 推广账号ID
+     * @param filtering 过滤条件
+     * @param page 页码
+     * @param pageSize 每页大小
+     * @return 短剧资产列表响应数据
+     * @throws TencentAdsResponseException API响应异常
+     * @throws TencentAdsSDKException SDK异常
+     */
+    public MarketingTargetAssetsGetResponseData getMarketingAssets(
+            Long orgAccountId,
+            String phone,
+            Long accountId,
+            List<FilteringStruct> filtering,
+            Long page,
+            Long pageSize) throws TencentAdsResponseException, TencentAdsSDKException, Exception {
+        
+        log.info("获取短剧资产列表, accountId: {}, page: {}, pageSize: {}", accountId, page, pageSize);
+        
+        TencentAds tencentAds = clientFactory.getTencentAds(orgAccountId, phone);
+        
+        MarketingTargetAssetsGetResponseData response = tencentAds.marketingTargetAssets()
+            .marketingTargetAssetsGet(
+                "MARKETING_TARGET_TYPE_SHORT_DRAMA",  // marketing_target_type
+                accountId,
+                filtering,
+                page,
+                pageSize,
+                null,  // organization_id
+                null   // fields
+            );
+        
+        log.info("获取短剧资产列表成功, 总数: {}", 
+            response.getPageInfo() != null ? response.getPageInfo().getTotalNumber() : 0);
+        
+        return response;
+    }
+
+    /**
+     * 同步短剧资产数据到数据库
+     * 每次查询后立即保存,最多同步1000条
+     * 
+     * @param orgAccountId 组织账户ID
+     * @param phone 手机号
+     * @param accountId 推广账号ID
+     * @return 同步的资产数量
+     * @throws Exception 异常
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public int syncMarketingAssets(Long orgAccountId, String phone, Long accountId) throws Exception {
+        log.info("开始同步短剧资产, accountId: {}", accountId);
+        
+        int totalCount = 0; // 总同步数量
+        int maxRecords = 1000; // 最多同步1000条
+        Long page = 1L;
+        Long pageSize = 100L;
+        
+        while (totalCount < maxRecords) {
+            MarketingTargetAssetsGetResponseData response = getMarketingAssets(
+                orgAccountId, phone, accountId, null, page, pageSize);
+            
+            if (response == null || CollectionUtils.isEmpty(response.getList())) {
+                log.info("没有更多数据,结束同步");
+                break;
+            }
+            
+            // 转换为实体对象
+            List<TencentAssetDrama> assets = convertToEntities(response);
+            
+            // 立即保存当前批次数据
+            if (!CollectionUtils.isEmpty(assets)) {
+                int count = tencentAssetDramaMapper.batchInsertOrUpdate(assets);
+                totalCount += count;
+                log.info("第{}页数据保存成功,本次影响行数: {},累计: {}", page, count, totalCount);
+            }
+            
+            // 判断是否达到上限
+            if (totalCount >= maxRecords) {
+                log.info("已达到最大同步数量{},结束同步", maxRecords);
+                break;
+            }
+            
+            // 判断是否还有下一页
+            if (response.getPageInfo() == null || 
+                page >= response.getPageInfo().getTotalPage()) {
+                log.info("已是最后一页,结束同步");
+                break;
+            }
+            
+            page++;
+        }
+        
+        log.info("同步短剧资产完成,总影响行数: {}", totalCount);
+        return totalCount;
+    }
+
+    /**
+     * 转换API响应为实体对象列表
+     * 
+     * @param response API响应数据
+     * @return 实体对象列表
+     */
+    private List<TencentAssetDrama> convertToEntities(MarketingTargetAssetsGetResponseData response) {
+        List<TencentAssetDrama> result = new ArrayList<>();
+        
+        if (response == null || CollectionUtils.isEmpty(response.getList())) {
+            return result;
+        }
+        
+        for (var item : response.getList()) {
+            TencentAssetDrama entity = new TencentAssetDrama();
+            
+            entity.setMarketingAssetId(item.getMarketingAssetId());
+            entity.setMarketingAssetName(item.getMarketingAssetName());
+            entity.setMarketingAssetType(item.getMarketingAssetType() != null ? 
+                item.getMarketingAssetType().toString() : null);
+            entity.setCreatedTime(item.getCreatedTime());
+            // SDK中MarketingAssetStruct没有isDeleted字段,默认设置为0
+            entity.setIsDeleted(0);
+            
+            // 将properties转为JSON字符串
+            if (item.getProperties() != null && !item.getProperties().isEmpty()) {
+                try {
+                    entity.setProperties(JSON.toJSONString(item.getProperties()));
+                } catch (Exception e) {
+                    log.warn("转换properties失败: {}", e.getMessage());
+                }
+            }
+            
+            result.add(entity);
+        }
+        
+        return result;
+    }
+}

+ 24 - 0
src/main/java/com/moka/gdtauto/service/TencentConversionService.java

@@ -0,0 +1,24 @@
+package com.moka.gdtauto.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.moka.gdtauto.entity.TencentConversion;
+
+import java.util.List;
+
+/**
+ * 腾讯广告转化归因服务接口
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+public interface TencentConversionService extends IService<TencentConversion> {
+
+    /**
+     * 从腾讯广告API同步转化归因数据
+     * 
+     * @param orgAccountId 组织账户ID
+     * @param accountId 广告账户ID
+     * @return 同步的转化数量
+     */
+    int syncConversions(Long orgAccountId, Long accountId);
+}

+ 27 - 0
src/main/java/com/moka/gdtauto/service/TencentImagesService.java

@@ -0,0 +1,27 @@
+package com.moka.gdtauto.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.moka.gdtauto.entity.TencentImages;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 腾讯广告图片Service接口
+ *
+ * @author moka
+ * @since 2026-02-06
+ */
+public interface TencentImagesService extends IService<TencentImages> {
+
+    /**
+     * 上传图片到腾讯广告
+     *
+     * @param file 图片文件
+     * @param accountId 广告账户ID
+     * @param hasWatermark 是否添加水印
+     * @param dramaId 剧ID(如果添加水印)
+     * @param description 图片描述
+     * @return 图片记录
+     */
+    TencentImages uploadImage(MultipartFile file, Long accountId,
+                               Boolean hasWatermark, Long dramaId, String description);
+}

+ 29 - 0
src/main/java/com/moka/gdtauto/service/TencentVideoService.java

@@ -0,0 +1,29 @@
+package com.moka.gdtauto.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.moka.gdtauto.entity.TencentVideo;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 腾讯广告视频Service接口
+ *
+ * @author moka
+ * @since 2026-02-06
+ */
+public interface TencentVideoService extends IService<TencentVideo> {
+
+    /**
+     * 上传视频到腾讯广告
+     *
+     * @param file 视频文件
+     * @param accountId 广告账户ID
+     * @param hasWatermark 是否添加水印
+     * @param dramaId 剧ID(如果添加水印)
+     * @param watermarkDuration 水印时长(秒),null表示全程
+     * @param description 视频描述
+     * @return 视频记录
+     */
+    TencentVideo uploadVideo(MultipartFile file, Long accountId,
+                             Boolean hasWatermark, Long dramaId, 
+                             Integer watermarkDuration, String description);
+}

+ 166 - 0
src/main/java/com/moka/gdtauto/service/impl/TencentAccountServiceImpl.java

@@ -0,0 +1,166 @@
+package com.moka.gdtauto.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+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.TencentAccount;
+import com.moka.gdtauto.mapper.TencentAccountMapper;
+import com.moka.gdtauto.service.TencentAccountService;
+import com.tencent.ads.model.v3.OrganizationAccountRelationGetResponseData;
+import com.tencent.ads.v3.TencentAds;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.CollectionUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 腾讯广告账户服务实现类
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TencentAccountServiceImpl extends ServiceImpl<TencentAccountMapper, TencentAccount> implements TencentAccountService {
+
+    private final TencentAccountMapper tencentAccountMapper;
+    private final TencentAdsApiClientFactory clientFactory;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int syncAccountsByOrgAccountId(Long orgAccountId) {
+        log.info("开始同步组织账户下的广告账户信息,orgAccountId={}", orgAccountId);
+        
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAdsWithoutUserToken(orgAccountId);
+            tencentAds.setDebug(true);
+            // 调用API获取组织账户下的广告账户列表
+            List<TencentAccount> accounts = new ArrayList<>();
+            Long pageSize = 100L;
+            Long cursor = null;  // 游标,第一次请求时为null
+            boolean hasMore = true;
+            
+            while (hasMore) {
+                log.info("获取数据,pageSize={}, cursor={}", pageSize, cursor);
+                
+                // 调用SDK的organizationAccountRelation().organizationAccountRelationGet()方法
+                try {
+                    OrganizationAccountRelationGetResponseData response = tencentAds.organizationAccountRelation()
+                        .organizationAccountRelationGet(
+                            "PAGINATION_MODE_CURSOR",  // paginationMode 使用游标翻页模式
+                            null,  // accountId
+                            null,  // advertiserType
+                            null,  // businessUnitId
+                            cursor,  // cursor 游标值
+                            null,  // page 游标模式下不需要page参数
+                            pageSize,
+                            null   // fields
+                        );
+                    
+                    if (response != null && response.getList() != null && !response.getList().isEmpty()) {
+                        log.info("获取到{}条账户数据", response.getList().size());
+                        
+                        // 转换为TencentAccount实体
+                        for (var item : response.getList()) {
+                            // 将item转换为JSON并再解析,以处理字段名问题
+                            String itemJson = JSON.toJSONString(item);
+                            com.alibaba.fastjson2.JSONObject itemObj = JSON.parseObject(itemJson);
+                            
+                            TencentAccount account = new TencentAccount();
+                            account.setOrgAccountId(orgAccountId);
+                            account.setAccountId(itemObj.getLong("account_id"));
+                            account.setCorporationName(itemObj.getString("corporation_name"));
+                            account.setIsBid(itemObj.getBoolean("is_bid"));
+                            account.setIsMp(itemObj.getBoolean("is_mp"));
+                            account.setIsAdx(itemObj.getBoolean("is_adx"));
+                            
+                            // 处理comment_list
+                            if (item.getCommentList() != null && !item.getCommentList().isEmpty()) {
+                                // 将comment_list转换为JSON字符串存储
+                                account.setCommentList(JSON.toJSONString(item.getCommentList()));
+                                
+                                // 取第一个元素的user_id和comment
+                                var firstComment = item.getCommentList().get(0);
+                                if (firstComment != null) {
+                                    account.setCommentUserId(firstComment.getUserId());
+                                    account.setComment(firstComment.getComment());
+                                }
+                            }
+                            
+                            accounts.add(account);
+                        }
+                        
+                        // 每次响应后立即批量插入或更新
+                        if (!accounts.isEmpty()) {
+                            int batchCount = batchInsertOrUpdate(accounts);
+                            log.info("本次响应插入或更新{}条账户数据", batchCount);
+                            accounts.clear(); // 清空列表,准备下一批
+                        }
+                        
+                        // 判断是否还有下一页(使用游标翻页信息)
+                        if (response.getCursorPageInfo() != null) {
+                            // 将CursorPageInfo转为JSON并解析,以处理字段名问题
+                            String cursorPageInfoJson = JSON.toJSONString(response.getCursorPageInfo());
+                            com.alibaba.fastjson2.JSONObject cursorPageInfoObj = JSON.parseObject(cursorPageInfoJson);
+                            
+                            Boolean hasMoreData = cursorPageInfoObj.getBoolean("has_more");
+                            hasMore = hasMoreData != null && hasMoreData;
+                            if (hasMore) {
+                                cursor = cursorPageInfoObj.getLong("cursor");
+                                log.info("还有更多数据,下次游标={}", cursor);
+                            } else {
+                                log.info("没有更多数据了,总共获取{}条", accounts.size());
+                            }
+                        } else {
+                            hasMore = false;
+                            log.warn("响应中没有cursor_page_info,停止翻页");
+                        }
+                    } else {
+                        hasMore = false;
+                        log.info("本次请求未获取到数据,停止翻页");
+                    }
+                    
+                } catch (Exception e) {
+                    log.error("调用API获取组织账户关系失败,orgAccountId={}, cursor={}", orgAccountId, cursor, e);
+                    throw new RuntimeException("调用API获取组织账户关系失败: " + e.getMessage(), e);
+                }
+            }
+            
+            // 最后返回总数
+            log.info("同步组织账户下的广告账户信息完成,orgAccountId={}", orgAccountId);
+            // 查询最终同步的总数
+            LambdaQueryWrapper<TencentAccount> countWrapper = new LambdaQueryWrapper<>();
+            countWrapper.eq(TencentAccount::getOrgAccountId, orgAccountId)
+                       .eq(TencentAccount::getDeleted, 0);
+            long totalCount = this.count(countWrapper);
+            log.info("最终同步总数={}", totalCount);
+            return (int) totalCount;
+            
+        } catch (Exception e) {
+            log.error("同步组织账户下的广告账户信息失败,orgAccountId={}", orgAccountId, e);
+            throw new RuntimeException("同步组织账户下的广告账户信息失败: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int batchInsertOrUpdate(List<TencentAccount> accounts) {
+        if (CollectionUtils.isEmpty(accounts)) {
+            log.warn("账户列表为空,无需批量插入或更新");
+            return 0;
+        }
+        
+        log.info("开始批量插入或更新账户信息,数量={}", accounts.size());
+        int count = tencentAccountMapper.batchInsertOrUpdate(accounts);
+        log.info("批量插入或更新账户信息完成,影响行数={}", count);
+        
+        return count;
+    }
+}

+ 146 - 0
src/main/java/com/moka/gdtauto/service/impl/TencentConversionServiceImpl.java

@@ -0,0 +1,146 @@
+package com.moka.gdtauto.service.impl;
+
+import com.alibaba.fastjson2.JSON;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.moka.gdtauto.entity.TencentConversion;
+import com.moka.gdtauto.mapper.TencentConversionMapper;
+import com.moka.gdtauto.service.TencentConversionService;
+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;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 腾讯广告转化归因服务实现类
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TencentConversionServiceImpl extends ServiceImpl<TencentConversionMapper, TencentConversion> implements TencentConversionService {
+
+    private final TencentConversionMapper tencentConversionMapper;
+    private final TencentAdsApiClientFactory clientFactory;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public int syncConversions(Long orgAccountId, Long accountId) {
+        log.info("开始同步转化归因数据,orgAccountId={}, accountId={}", orgAccountId, accountId);
+        
+        try {
+            // 获取TencentAds实例(使用组织账户ID)
+            TencentAds tencentAds = clientFactory.getTencentAdsWithoutUserToken(orgAccountId);
+            tencentAds.setDebug(true);
+            
+            // 构建过滤条件:短剧用户增长注册变现
+            List<FilteringStruct> filtering = buildFiltering();
+            
+            int syncCount = 0;
+            Long page = 1L;
+            Long pageSize = 100L;
+            boolean hasMore = true;
+            
+            while (hasMore) {
+                log.info("获取转化归因数据,page={}, pageSize={}", page, pageSize);
+                
+                try {
+                    // 调用API查询转化归因
+                    ConversionsGetResponseData response = tencentAds.conversions()
+                        .conversionsGet(
+                            accountId,
+                            filtering,
+                            page,
+                            pageSize,
+                            List.of("conversion_id", "conversion_name")  // 只获取需要的字段
+                        );
+                    
+                    if (response != null && response.getList() != null && !response.getList().isEmpty()) {
+                        log.info("获取到{}条转化归因数据", response.getList().size());
+                        
+                        // 批量保存到数据库
+                        List<TencentConversion> conversions = new ArrayList<>();
+                        for (ConversionsGetListStruct item : response.getList()) {
+                            TencentConversion conversion = new TencentConversion();
+                            conversion.setConversionId(item.getConversionId());
+                            conversion.setConversionName(item.getConversionName());
+                            conversions.add(conversion);
+                        }
+                        
+                        // 使用 saveOrUpdateBatch 批量保存或更新
+                        this.saveOrUpdateBatch(conversions);
+                        syncCount += conversions.size();
+                        
+                        // 判断是否还有下一页
+                        if (response.getPageInfo() != null) {
+                            Long totalNumber = response.getPageInfo().getTotalNumber();
+                            Long totalPage = response.getPageInfo().getTotalPage();
+                            log.info("当前页={}, 总页数={}, 总记录数={}", page, totalPage, totalNumber);
+                            
+                            if (page < totalPage) {
+                                page++;
+                            } else {
+                                hasMore = false;
+                                log.info("已到最后一页,停止翻页");
+                            }
+                        } else {
+                            hasMore = false;
+                            log.warn("响应中没有page_info,停止翻页");
+                        }
+                    } else {
+                        hasMore = false;
+                        log.info("本次请求未获取到数据,停止翻页");
+                    }
+                    
+                } catch (Exception e) {
+                    log.error("调用API获取转化归因失败,accountId={}, page={}", accountId, page, e);
+                    throw new RuntimeException("调用API获取转化归因失败: " + e.getMessage(), e);
+                }
+            }
+            
+            log.info("同步转化归因数据完成,orgAccountId={}, accountId={}, 同步数量={}", orgAccountId, accountId, syncCount);
+            return syncCount;
+            
+        } catch (Exception e) {
+            log.error("同步转化归因数据失败,orgAccountId={}, accountId={}", orgAccountId, accountId, e);
+            throw new RuntimeException("同步转化归因数据失败: " + e.getMessage(), e);
+        }
+    }
+    
+    /**
+     * 构建过滤条件:短剧用户增长注册变现
+     */
+    private List<FilteringStruct> buildFiltering() {
+        List<FilteringStruct> filtering = new ArrayList<>();
+        
+        // 过滤条件1:优化目标 = OPTIMIZATIONGOAL_APP_REGISTER
+        FilteringStruct filter1 = new FilteringStruct();
+        filter1.setField("optimization_goal");
+        filter1.setOperator(FilterOperator.EQUALS);
+        filter1.setValues(List.of("OPTIMIZATIONGOAL_APP_REGISTER"));
+        filtering.add(filter1);
+        
+        // 过滤条件2:营销目标 = MARKETING_GOAL_USER_GROWTH
+        FilteringStruct filter2 = new FilteringStruct();
+        filter2.setField("marketing_goal");
+        filter2.setOperator(FilterOperator.EQUALS);
+        filter2.setValues(List.of("MARKETING_GOAL_USER_GROWTH"));
+        filtering.add(filter2);
+        
+        // 过滤条件3:营销资产类型 = MARKETING_TARGET_TYPE_SHORT_DRAMA
+        FilteringStruct filter3 = new FilteringStruct();
+        filter3.setField("marketing_target_type");
+        filter3.setOperator(FilterOperator.EQUALS);
+        filter3.setValues(List.of("MARKETING_TARGET_TYPE_SHORT_DRAMA"));
+        filtering.add(filter3);
+        
+        return filtering;
+    }
+}

+ 201 - 0
src/main/java/com/moka/gdtauto/service/impl/TencentImagesServiceImpl.java

@@ -0,0 +1,201 @@
+package com.moka.gdtauto.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.moka.gdtauto.entity.TencentAssetDrama;
+import com.moka.gdtauto.entity.TencentImages;
+import com.moka.gdtauto.mapper.TencentImagesMapper;
+import com.moka.gdtauto.service.TencentAssetDramaService;
+import com.moka.gdtauto.service.TencentImagesService;
+import com.moka.gdtauto.util.ImageWatermarkUtil;
+import com.tencent.ads.model.v3.ImagesAddResponseData;
+import com.tencent.ads.v3.TencentAds;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.time.LocalDateTime;
+
+/**
+ * 腾讯广告图片Service实现类
+ *
+ * @author moka
+ * @since 2026-02-06
+ */
+@Slf4j
+@Service
+public class TencentImagesServiceImpl extends ServiceImpl<TencentImagesMapper, TencentImages> 
+        implements TencentImagesService {
+
+    @Autowired
+    private TencentAdsApiClientFactory clientFactory;
+
+    @Autowired
+    private TencentAssetDramaService dramaService;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public TencentImages uploadImage(MultipartFile file, Long accountId, 
+                                      Boolean hasWatermark, Long dramaId, String description) {
+        TencentImages imageRecord = new TencentImages();
+        imageRecord.setAccountId(accountId);
+        imageRecord.setOriginalFilename(file.getOriginalFilename());
+        imageRecord.setHasWatermark(hasWatermark != null && hasWatermark);
+        imageRecord.setDramaId(dramaId);
+        imageRecord.setDescription(description);
+        imageRecord.setCreatedTime(LocalDateTime.now());
+        imageRecord.setUpdatedTime(LocalDateTime.now());
+
+        File tempFile = null;
+        File watermarkedFile = null;
+
+        try {
+            // 1. 保存临时文件
+            tempFile = File.createTempFile("upload-", getFileExtension(file.getOriginalFilename()));
+            file.transferTo(tempFile);
+            log.info("临时文件保存成功: {}", tempFile.getAbsolutePath());
+
+            File fileToUpload = tempFile;
+
+            // 2. 如果需要添加水印
+            if (hasWatermark != null && hasWatermark && dramaId != null) {
+                TencentAssetDrama drama = dramaService.getById(dramaId);
+                if (drama != null && drama.getMarketingAssetName() != null) {
+                    log.info("开始添加水印,剧名: {}", drama.getMarketingAssetName());
+                    
+                    // 创建水印文件
+                    watermarkedFile = File.createTempFile("watermarked-", getFileExtension(file.getOriginalFilename()));
+                    
+                    // 添加水印到左上角,使用默认字体大小48,位置(50, 50)
+                    ImageWatermarkUtil.addWatermark(
+                        tempFile.getAbsolutePath(), 
+                        watermarkedFile.getAbsolutePath(), 
+                        drama.getMarketingAssetName(),
+                        48,  // 字体大小
+                        50,  // x坐标 - 左上角
+                        50   // y坐标 - 左上角
+                    );
+                    
+                    fileToUpload = watermarkedFile;
+                    imageRecord.setWatermarkText(drama.getMarketingAssetName());
+                    log.info("水印添加成功: {}", drama.getMarketingAssetName());
+                }
+            }
+
+            // 3. 计算MD5签名
+            String signature = calculateMD5(fileToUpload);
+            log.info("文件MD5签名: {}", signature);
+
+            // 4. 获取TencentAds客户端
+            TencentAds tencentAds = clientFactory.getTencentAdsWithoutUserToken(accountId);
+            log.info("开始上传图片到腾讯广告,账户ID: {}", accountId);
+
+            // 5. 调用腾讯广告API上传图片
+            ImagesAddResponseData response = tencentAds.images()
+                .imagesAdd(
+                    "UPLOAD_TYPE_FILE",
+                    signature,
+                    accountId,
+                    null,
+                    fileToUpload,
+                    null,
+                    null,
+                    description,
+                    null,
+                    null,
+                    null
+                );
+
+            // 6. 保存上传结果
+            if (response != null) {
+                imageRecord.setImageId(response.getImageId());
+                imageRecord.setImageWidth(response.getImageWidth() != null ? response.getImageWidth().intValue() : null);
+                imageRecord.setImageHeight(response.getImageHeight() != null ? response.getImageHeight().intValue() : null);
+                imageRecord.setImageFileSize(response.getImageFileSize());
+                imageRecord.setImageType(response.getImageType() != null ? response.getImageType().toString() : null);
+                imageRecord.setPreviewUrl(response.getPreviewUrl());
+                imageRecord.setUploadStatus("SUCCESS");
+                
+                log.info("图片上传成功 - ID: {}, 宽度: {}px, 高度: {}px, 大小: {}B, 类型: {}", 
+                    response.getImageId(), 
+                    response.getImageWidth(), 
+                    response.getImageHeight(), 
+                    response.getImageFileSize(), 
+                    response.getImageType());
+            } else {
+                imageRecord.setUploadStatus("FAILED");
+                imageRecord.setErrorMessage("上传返回结果为空");
+                log.error("图片上传失败: 返回结果为空");
+            }
+
+        } catch (Exception e) {
+            log.error("图片上传失败", e);
+            imageRecord.setUploadStatus("FAILED");
+            imageRecord.setErrorMessage(e.getMessage());
+        } finally {
+            // 清理临时文件
+            if (tempFile != null && tempFile.exists()) {
+                try {
+                    Files.delete(tempFile.toPath());
+                    log.debug("临时文件删除成功: {}", tempFile.getAbsolutePath());
+                } catch (Exception e) {
+                    log.warn("临时文件删除失败: {}", tempFile.getAbsolutePath(), e);
+                }
+            }
+            if (watermarkedFile != null && watermarkedFile.exists()) {
+                try {
+                    Files.delete(watermarkedFile.toPath());
+                    log.debug("水印文件删除成功: {}", watermarkedFile.getAbsolutePath());
+                } catch (Exception e) {
+                    log.warn("水印文件删除失败: {}", watermarkedFile.getAbsolutePath(), e);
+                }
+            }
+        }
+
+        // 7. 保存记录到数据库
+        this.save(imageRecord);
+        log.info("图片记录保存成功,ID: {}", imageRecord.getId());
+
+        return imageRecord;
+    }
+
+    /**
+     * 计算文件的MD5签名
+     */
+    private String calculateMD5(File file) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("MD5");
+        try (FileInputStream fis = new FileInputStream(file)) {
+            byte[] buffer = new byte[8192];
+            int bytesRead;
+            while ((bytesRead = fis.read(buffer)) != -1) {
+                md.update(buffer, 0, bytesRead);
+            }
+        }
+        byte[] digest = md.digest();
+        BigInteger bigInt = new BigInteger(1, digest);
+        String hashtext = bigInt.toString(16);
+        // 补齐前导0
+        while (hashtext.length() < 32) {
+            hashtext = "0" + hashtext;
+        }
+        return hashtext;
+    }
+
+    /**
+     * 获取文件扩展名
+     */
+    private String getFileExtension(String filename) {
+        if (filename == null || !filename.contains(".")) {
+            return ".jpg";
+        }
+        return filename.substring(filename.lastIndexOf("."));
+    }
+}

+ 224 - 0
src/main/java/com/moka/gdtauto/service/impl/TencentVideoServiceImpl.java

@@ -0,0 +1,224 @@
+package com.moka.gdtauto.service.impl;
+
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.moka.gdtauto.entity.TencentAssetDrama;
+import com.moka.gdtauto.entity.TencentVideo;
+import com.moka.gdtauto.mapper.TencentVideoMapper;
+import com.moka.gdtauto.service.TencentAssetDramaService;
+import com.moka.gdtauto.service.TencentVideoService;
+import com.moka.gdtauto.util.VideoWatermarkUtil;
+import com.tencent.ads.model.v3.VideosAddResponseData;
+import com.tencent.ads.v3.TencentAds;
+import lombok.extern.slf4j.Slf4j;
+import org.bytedeco.javacv.FFmpegFrameGrabber;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.security.MessageDigest;
+import java.time.LocalDateTime;
+
+/**
+ * 腾讯广告视频Service实现类
+ *
+ * @author moka
+ * @since 2026-02-06
+ */
+@Slf4j
+@Service
+public class TencentVideoServiceImpl extends ServiceImpl<TencentVideoMapper, TencentVideo>
+        implements TencentVideoService {
+
+    @Autowired
+    private TencentAdsApiClientFactory clientFactory;
+
+    @Autowired
+    private TencentAssetDramaService dramaService;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public TencentVideo uploadVideo(MultipartFile file, Long accountId,
+                                    Boolean hasWatermark, Long dramaId,
+                                    Integer watermarkDuration, String description) {
+        TencentVideo videoRecord = new TencentVideo();
+        videoRecord.setAccountId(accountId);
+        videoRecord.setOriginalFilename(file.getOriginalFilename());
+        videoRecord.setHasWatermark(hasWatermark != null && hasWatermark);
+        videoRecord.setDramaId(dramaId);
+        videoRecord.setWatermarkDuration(watermarkDuration);
+        videoRecord.setDescription(description);
+        videoRecord.setCreatedTime(LocalDateTime.now());
+        videoRecord.setUpdatedTime(LocalDateTime.now());
+
+        File tempFile = null;
+        File watermarkedFile = null;
+
+        try {
+            // 1. 保存临时文件
+            tempFile = File.createTempFile("upload-video-", getFileExtension(file.getOriginalFilename()));
+            file.transferTo(tempFile);
+            log.info("临时视频文件保存成功: {}", tempFile.getAbsolutePath());
+
+            // 获取视频时长
+            Integer videoDuration = getVideoDuration(tempFile);
+            if (videoDuration != null) {
+                videoRecord.setVideoDuration(videoDuration);
+                log.info("视频时长: {} 秒", videoDuration);
+            }
+
+            File fileToUpload = tempFile;
+
+            // 2. 如果需要添加水印
+            if (hasWatermark != null && hasWatermark && dramaId != null) {
+                TencentAssetDrama drama = dramaService.getById(dramaId);
+                if (drama != null && drama.getMarketingAssetName() != null) {
+                    log.info("开始添加水印,剧名: {}, 水印时长: {} 秒", 
+                        drama.getMarketingAssetName(), 
+                        watermarkDuration == null ? "全程" : watermarkDuration);
+
+                    // 创建水印文件
+                    watermarkedFile = File.createTempFile("watermarked-video-", ".mp4");
+
+                    // 添加水印到左上角
+                    VideoWatermarkUtil.addWatermark(
+                            tempFile.getAbsolutePath(),
+                            watermarkedFile.getAbsolutePath(),
+                            drama.getMarketingAssetName(),
+                            watermarkDuration  // null表示全程,否则为指定秒数
+                    );
+
+                    fileToUpload = watermarkedFile;
+                    videoRecord.setWatermarkText(drama.getMarketingAssetName());
+                    log.info("水印添加成功: {}", drama.getMarketingAssetName());
+                }
+            }
+
+            // 3. 计算MD5签名
+            String signature = calculateMD5(fileToUpload);
+            log.info("视频MD5签名: {}", signature);
+
+            // 4. 获取TencentAds客户端
+            TencentAds tencentAds = clientFactory.getTencentAdsWithoutUserToken(accountId);
+            log.info("开始上传视频到腾讯广告,账户ID: {}", accountId);
+
+            // 5. 调用腾讯广告API上传视频
+            VideosAddResponseData response = tencentAds.videos()
+                    .videosAdd(
+                            fileToUpload,
+                            signature,
+                            accountId,
+                            null,
+                            description,
+                            null
+                    );
+
+            // 6. 保存上传结果
+            if (response != null) {
+                videoRecord.setVideoId(response.getVideoId() != null ? response.getVideoId().toString() : null);
+                videoRecord.setCoverImageId(response.getCoverImageId() != null ? response.getCoverImageId().toString() : null);
+                videoRecord.setUploadStatus("SUCCESS");
+
+                log.info("视频上传成功 - 视频ID: {}, 封面图片ID: {}",
+                        response.getVideoId(),
+                        response.getCoverImageId());
+            } else {
+                videoRecord.setUploadStatus("FAILED");
+                videoRecord.setErrorMessage("上传返回结果为空");
+                log.error("视频上传失败: 返回结果为空");
+            }
+
+        } catch (Exception e) {
+            log.error("视频上传失败", e);
+            videoRecord.setUploadStatus("FAILED");
+            videoRecord.setErrorMessage(e.getMessage());
+        } finally {
+            // 清理临时文件
+            if (tempFile != null && tempFile.exists()) {
+                try {
+                    Files.delete(tempFile.toPath());
+                    log.debug("临时文件删除成功: {}", tempFile.getAbsolutePath());
+                } catch (Exception e) {
+                    log.warn("临时文件删除失败: {}", tempFile.getAbsolutePath(), e);
+                }
+            }
+            if (watermarkedFile != null && watermarkedFile.exists()) {
+                try {
+                    Files.delete(watermarkedFile.toPath());
+                    log.debug("水印文件删除成功: {}", watermarkedFile.getAbsolutePath());
+                } catch (Exception e) {
+                    log.warn("水印文件删除失败: {}", watermarkedFile.getAbsolutePath(), e);
+                }
+            }
+        }
+
+        // 7. 保存记录到数据库
+        this.save(videoRecord);
+        log.info("视频记录保存成功,ID: {}", videoRecord.getId());
+
+        return videoRecord;
+    }
+
+    /**
+     * 获取视频时长(秒)
+     */
+    private Integer getVideoDuration(File videoFile) {
+        FFmpegFrameGrabber grabber = null;
+        try {
+            grabber = new FFmpegFrameGrabber(videoFile);
+            grabber.start();
+            long lengthInMicroseconds = grabber.getLengthInTime();
+            int durationInSeconds = (int) (lengthInMicroseconds / 1000000);
+            return durationInSeconds;
+        } catch (Exception e) {
+            log.warn("获取视频时长失败", e);
+            return null;
+        } finally {
+            if (grabber != null) {
+                try {
+                    grabber.stop();
+                    grabber.release();
+                } catch (Exception e) {
+                    // ignore
+                }
+            }
+        }
+    }
+
+    /**
+     * 计算文件的MD5签名
+     */
+    private String calculateMD5(File file) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("MD5");
+        try (FileInputStream fis = new FileInputStream(file)) {
+            byte[] buffer = new byte[8192];
+            int bytesRead;
+            while ((bytesRead = fis.read(buffer)) != -1) {
+                md.update(buffer, 0, bytesRead);
+            }
+        }
+        byte[] digest = md.digest();
+        BigInteger bigInt = new BigInteger(1, digest);
+        String hashtext = bigInt.toString(16);
+        // 补齐前导0
+        while (hashtext.length() < 32) {
+            hashtext = "0" + hashtext;
+        }
+        return hashtext;
+    }
+
+    /**
+     * 获取文件扩展名
+     */
+    private String getFileExtension(String filename) {
+        if (filename == null || !filename.contains(".")) {
+            return ".mp4";
+        }
+        return filename.substring(filename.lastIndexOf("."));
+    }
+}

+ 162 - 0
src/main/java/com/moka/gdtauto/util/ImageWatermarkUtil.java

@@ -0,0 +1,162 @@
+package com.moka.gdtauto.util;
+
+import lombok.extern.slf4j.Slf4j;
+import net.coobird.thumbnailator.Thumbnails;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * 图片水印工具类
+ * 用于在图片左上角添加文字水印(如剧名)
+ *
+ * @author moka
+ */
+@Slf4j
+public class ImageWatermarkUtil {
+
+    /**
+     * 在图片左上角添加文字水印
+     *
+     * @param inputImagePath  输入图片路径
+     * @param outputImagePath 输出图片路径
+     * @param watermarkText   水印文字(如:《绝世龙王》)
+     * @throws IOException IO异常
+     */
+    public static void addWatermark(String inputImagePath, String outputImagePath, String watermarkText) throws IOException {
+        addWatermark(inputImagePath, outputImagePath, watermarkText, 40, 50, 50);
+    }
+
+    /**
+     * 在图片左上角添加文字水印(可自定义位置和字体大小)
+     *
+     * @param inputImagePath  输入图片路径
+     * @param outputImagePath 输出图片路径
+     * @param watermarkText   水印文字(如:《绝世龙王》)
+     * @param fontSize        字体大小
+     * @param x               水印X坐标(从左边距)
+     * @param y               水印Y坐标(从顶部距)
+     * @throws IOException IO异常
+     */
+    public static void addWatermark(String inputImagePath, String outputImagePath, 
+                                   String watermarkText, int fontSize, int x, int y) throws IOException {
+        log.info("开始为图片添加水印, 输入路径: {}, 输出路径: {}, 水印文字: {}", inputImagePath, outputImagePath, watermarkText);
+        
+        // 读取原始图片
+        BufferedImage originalImage = ImageIO.read(new File(inputImagePath));
+        
+        // 创建Graphics2D对象
+        Graphics2D g2d = originalImage.createGraphics();
+        
+        // 设置抗锯齿
+        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+        
+        // 设置字体(使用系统默认黑体或宋体)
+        Font font = new Font("Microsoft YaHei", Font.BOLD, fontSize);
+        g2d.setFont(font);
+        
+        // 设置颜色和透明度
+        g2d.setColor(new Color(255, 255, 255, 230)); // 白色,透明度230
+        
+        // 添加阴影效果(描边)
+        g2d.setStroke(new BasicStroke(2));
+        FontMetrics fontMetrics = g2d.getFontMetrics();
+        
+        // 绘制黑色描边
+        g2d.setColor(new Color(0, 0, 0, 180));
+        for (int i = -1; i <= 1; i++) {
+            for (int j = -1; j <= 1; j++) {
+                g2d.drawString(watermarkText, x + i, y + j);
+            }
+        }
+        
+        // 绘制白色文字
+        g2d.setColor(new Color(255, 255, 255, 230));
+        g2d.drawString(watermarkText, x, y);
+        
+        // 释放资源
+        g2d.dispose();
+        
+        // 保存图片
+        File outputFile = new File(outputImagePath);
+        File parentDir = outputFile.getParentFile();
+        if (parentDir != null && !parentDir.exists()) {
+            parentDir.mkdirs();
+        }
+        
+        String formatName = getImageFormat(outputImagePath);
+        ImageIO.write(originalImage, formatName, outputFile);
+        
+        log.info("图片水印添加成功,保存到: {}", outputImagePath);
+    }
+
+    /**
+     * 使用Thumbnailator库添加水印(支持压缩)
+     *
+     * @param inputImagePath  输入图片路径
+     * @param outputImagePath 输出图片路径
+     * @param watermarkText   水印文字
+     * @param scale           缩放比例(1.0表示不缩放)
+     * @throws IOException IO异常
+     */
+    public static void addWatermarkWithCompression(String inputImagePath, String outputImagePath, 
+                                                  String watermarkText, double scale) throws IOException {
+        log.info("开始为图片添加水印(压缩模式), 缩放比例: {}", scale);
+        
+        // 读取原始图片
+        BufferedImage originalImage = ImageIO.read(new File(inputImagePath));
+        
+        // 添加水印
+        Graphics2D g2d = originalImage.createGraphics();
+        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+        
+        Font font = new Font("Microsoft YaHei", Font.BOLD, 40);
+        g2d.setFont(font);
+        
+        // 黑色描边
+        g2d.setColor(new Color(0, 0, 0, 180));
+        for (int i = -1; i <= 1; i++) {
+            for (int j = -1; j <= 1; j++) {
+                g2d.drawString(watermarkText, 50 + i, 50 + j);
+            }
+        }
+        
+        // 白色文字
+        g2d.setColor(new Color(255, 255, 255, 230));
+        g2d.drawString(watermarkText, 50, 50);
+        g2d.dispose();
+        
+        // 使用Thumbnailator压缩并保存
+        Thumbnails.of(originalImage)
+                .scale(scale)
+                .outputQuality(0.9)
+                .toFile(outputImagePath);
+        
+        log.info("图片水印添加成功(已压缩),保存到: {}", outputImagePath);
+    }
+
+    /**
+     * 获取图片格式
+     */
+    private static String getImageFormat(String filePath) {
+        String extension = filePath.substring(filePath.lastIndexOf(".") + 1).toLowerCase();
+        switch (extension) {
+            case "jpg":
+            case "jpeg":
+                return "jpg";
+            case "png":
+                return "png";
+            case "gif":
+                return "gif";
+            case "bmp":
+                return "bmp";
+            default:
+                return "jpg";
+        }
+    }
+}

+ 240 - 0
src/main/java/com/moka/gdtauto/util/VideoWatermarkUtil.java

@@ -0,0 +1,240 @@
+package com.moka.gdtauto.util;
+
+import lombok.extern.slf4j.Slf4j;
+import org.bytedeco.javacv.FFmpegFrameGrabber;
+import org.bytedeco.javacv.FFmpegFrameRecorder;
+import org.bytedeco.javacv.Frame;
+import org.bytedeco.javacv.Java2DFrameConverter;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * 视频水印工具类
+ * 用于在视频左上角添加文字水印(如剧名)
+ * 使用JavaCV + FFmpeg实现
+ *
+ * @author moka
+ */
+@Slf4j
+public class VideoWatermarkUtil {
+
+    /**
+     * 在视频左上角添加文字水印(全程)
+     *
+     * @param inputVideoPath  输入视频路径
+     * @param outputVideoPath 输出视频路径
+     * @param watermarkText   水印文字(如:《绝世龙王》)
+     * @throws Exception 处理异常
+     */
+    public static void addWatermark(String inputVideoPath, String outputVideoPath, String watermarkText) throws Exception {
+        addWatermark(inputVideoPath, outputVideoPath, watermarkText, null, 40, 50, 50);
+    }
+
+    /**
+     * 在视频左上角添加文字水印(支持指定前N秒)
+     *
+     * @param inputVideoPath    输入视频路径
+     * @param outputVideoPath   输出视频路径
+     * @param watermarkText     水印文字(如:《绝世龙王》)
+     * @param watermarkDuration 水印时长(秒),null表示全程
+     * @throws Exception 处理异常
+     */
+    public static void addWatermark(String inputVideoPath, String outputVideoPath, 
+                                   String watermarkText, Integer watermarkDuration) throws Exception {
+        addWatermark(inputVideoPath, outputVideoPath, watermarkText, watermarkDuration, 40, 50, 50);
+    }
+
+    /**
+     * 在视频左上角添加文字水印(可自定义位置和字体大小、时长)
+     *
+     * @param inputVideoPath    输入视频路径
+     * @param outputVideoPath   输出视频路径
+     * @param watermarkText     水印文字(如:《绝世龙王》)
+     * @param watermarkDuration 水印时长(秒),null表示全程
+     * @param fontSize          字体大小
+     * @param x                 水印X坐标(从左边距)
+     * @param y                 水印Y坐标(从顶部距)
+     * @throws Exception 处理异常
+     */
+    public static void addWatermark(String inputVideoPath, String outputVideoPath,
+                                   String watermarkText, Integer watermarkDuration,
+                                   int fontSize, int x, int y) throws Exception {
+        log.info("开始为视频添加水印, 输入路径: {}, 输出路径: {}, 水印文字: {}", inputVideoPath, outputVideoPath, watermarkText);
+
+        FFmpegFrameGrabber grabber = null;
+        FFmpegFrameRecorder recorder = null;
+        Java2DFrameConverter converter = new Java2DFrameConverter();
+
+        try {
+            // 创建视频抓取器
+            grabber = new FFmpegFrameGrabber(inputVideoPath);
+            grabber.start();
+
+            // 获取视频信息
+            int videoWidth = grabber.getImageWidth();
+            int videoHeight = grabber.getImageHeight();
+            double frameRate = grabber.getFrameRate();
+            int audioBitrate = grabber.getAudioBitrate();
+            int videoBitrate = grabber.getVideoBitrate();
+            String videoCodecName = grabber.getVideoCodec() != 0 ? "h264" : "h264";
+            String audioCodecName = grabber.getAudioCodec() != 0 ? "aac" : "aac";
+
+            log.info("视频信息 - 分辨率: {}x{}, 帧率: {}, 视频码率: {}, 音频码率: {}",
+                    videoWidth, videoHeight, frameRate, videoBitrate, audioBitrate);
+
+            // 创建输出目录
+            File outputFile = new File(outputVideoPath);
+            File parentDir = outputFile.getParentFile();
+            if (parentDir != null && !parentDir.exists()) {
+                parentDir.mkdirs();
+            }
+
+            // 创建视频录制器
+            recorder = new FFmpegFrameRecorder(outputVideoPath, videoWidth, videoHeight, grabber.getAudioChannels());
+            recorder.setVideoCodec(org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_H264);
+            recorder.setFormat("mp4");
+            recorder.setFrameRate(frameRate);
+            recorder.setVideoBitrate(videoBitrate > 0 ? videoBitrate : 2000000);
+            
+            if (grabber.getAudioChannels() > 0) {
+                recorder.setAudioCodec(org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_AAC);
+                recorder.setAudioBitrate(audioBitrate > 0 ? audioBitrate : 128000);
+                recorder.setSampleRate(grabber.getSampleRate());
+            }
+
+            recorder.start();
+
+            // 计算水印结束帧数(如果指定了水印时长)
+            Integer watermarkEndFrame = null;
+            if (watermarkDuration != null && watermarkDuration > 0) {
+                watermarkEndFrame = (int) (watermarkDuration * frameRate);
+                log.info("水印将在前 {} 秒({}帧)显示", watermarkDuration, watermarkEndFrame);
+            }
+
+            // 处理每一帧
+            Frame frame;
+            int frameCount = 0;
+            int imageFrameCount = 0;
+            while ((frame = grabber.grab()) != null) {
+                // 如果是图像帧,判断是否添加水印
+                if (frame.image != null) {
+                    imageFrameCount++;
+                    BufferedImage bufferedImage = converter.convert(frame);
+                    if (bufferedImage != null) {
+                        // 如果在水印时长范围内(或全程显示),添加水印
+                        boolean shouldAddWatermark = (watermarkEndFrame == null) || (imageFrameCount <= watermarkEndFrame);
+                        if (shouldAddWatermark) {
+                            addTextWatermark(bufferedImage, watermarkText, fontSize, x, y);
+                        }
+                        frame = converter.convert(bufferedImage);
+                    }
+                    frameCount++;
+                    if (frameCount % 100 == 0) {
+                        log.info("已处理 {} 帧", frameCount);
+                    }
+                }
+                
+                // 录制帧(包括音频帧)
+                recorder.record(frame);
+            }
+
+            log.info("视频水印添加成功,总共处理 {} 帧,保存到: {}", frameCount, outputVideoPath);
+
+        } finally {
+            // 释放资源
+            if (grabber != null) {
+                try {
+                    grabber.stop();
+                    grabber.release();
+                } catch (Exception e) {
+                    log.error("关闭grabber失败", e);
+                }
+            }
+            if (recorder != null) {
+                try {
+                    recorder.stop();
+                    recorder.release();
+                } catch (Exception e) {
+                    log.error("关闭recorder失败", e);
+                }
+            }
+            converter.close();
+        }
+    }
+
+    /**
+     * 在BufferedImage上添加文字水印
+     */
+    private static void addTextWatermark(BufferedImage image, String text, int fontSize, int x, int y) {
+        Graphics2D g2d = image.createGraphics();
+
+        // 设置抗锯齿
+        g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+        g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+
+        // 设置字体
+        Font font = new Font("Microsoft YaHei", Font.BOLD, fontSize);
+        g2d.setFont(font);
+
+        // 绘制黑色描边
+        g2d.setColor(new Color(0, 0, 0, 180));
+        for (int i = -1; i <= 1; i++) {
+            for (int j = -1; j <= 1; j++) {
+                g2d.drawString(text, x + i, y + j);
+            }
+        }
+
+        // 绘制白色文字
+        g2d.setColor(new Color(255, 255, 255, 230));
+        g2d.drawString(text, x, y);
+
+        g2d.dispose();
+    }
+
+    /**
+     * 提取视频第一帧作为预览图
+     *
+     * @param videoPath 视频路径
+     * @param imagePath 输出图片路径
+     * @throws Exception 处理异常
+     */
+    public static void extractFirstFrame(String videoPath, String imagePath) throws Exception {
+        FFmpegFrameGrabber grabber = null;
+        Java2DFrameConverter converter = new Java2DFrameConverter();
+
+        try {
+            grabber = new FFmpegFrameGrabber(videoPath);
+            grabber.start();
+
+            Frame frame = grabber.grabImage();
+            if (frame != null) {
+                BufferedImage image = converter.convert(frame);
+                if (image != null) {
+                    File outputFile = new File(imagePath);
+                    File parentDir = outputFile.getParentFile();
+                    if (parentDir != null && !parentDir.exists()) {
+                        parentDir.mkdirs();
+                    }
+
+                    String format = imagePath.endsWith(".png") ? "png" : "jpg";
+                    ImageIO.write(image, format, outputFile);
+                    log.info("成功提取视频第一帧: {}", imagePath);
+                }
+            }
+        } finally {
+            if (grabber != null) {
+                try {
+                    grabber.stop();
+                    grabber.release();
+                } catch (Exception e) {
+                    log.error("关闭grabber失败", e);
+                }
+            }
+            converter.close();
+        }
+    }
+}

+ 46 - 0
src/main/resources/mapper/TencentAccountMapper.xml

@@ -0,0 +1,46 @@
+<?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.TencentAccountMapper">
+
+    <!-- 批量插入或更新账户信息 -->
+    <insert id="batchInsertOrUpdate" parameterType="java.util.List">
+        INSERT INTO tencent_account (
+            org_account_id,
+            account_id,
+            corporation_name,
+            is_bid,
+            is_mp,
+            is_adx,
+            comment_list,
+            comment_user_id,
+            comment,
+            create_time,
+            update_time
+        ) VALUES
+        <foreach collection="accounts" item="account" separator=",">
+            (
+                #{account.orgAccountId},
+                #{account.accountId},
+                #{account.corporationName},
+                #{account.isBid},
+                #{account.isMp},
+                #{account.isAdx},
+                #{account.commentList},
+                #{account.commentUserId},
+                #{account.comment},
+                NOW(),
+                NOW()
+            )
+        </foreach>
+        ON DUPLICATE KEY UPDATE
+            corporation_name = VALUES(corporation_name),
+            is_bid = VALUES(is_bid),
+            is_mp = VALUES(is_mp),
+            is_adx = VALUES(is_adx),
+            comment_list = VALUES(comment_list),
+            comment_user_id = VALUES(comment_user_id),
+            comment = VALUES(comment),
+            update_time = NOW()
+    </insert>
+
+</mapper>

+ 36 - 0
src/main/resources/mapper/TencentAssetDramaMapper.xml

@@ -0,0 +1,36 @@
+<?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.TencentAssetDramaMapper">
+
+    <!-- 批量插入或更新短剧资产 -->
+    <insert id="batchInsertOrUpdate" parameterType="java.util.List">
+        INSERT INTO tencent_asset_drama (
+            marketing_asset_id,
+            marketing_asset_name,
+            marketing_asset_type,
+            created_time,
+            is_deleted,
+            properties
+        )
+        VALUES
+        <foreach collection="dramas" item="item" separator=",">
+            (
+                #{item.marketingAssetId},
+                #{item.marketingAssetName},
+                #{item.marketingAssetType},
+                #{item.createdTime},
+                #{item.isDeleted},
+                #{item.properties}
+            )
+        </foreach>
+        ON DUPLICATE KEY UPDATE
+            marketing_asset_name = VALUES(marketing_asset_name),
+            marketing_asset_type = VALUES(marketing_asset_type),
+            created_time = VALUES(created_time),
+            is_deleted = VALUES(is_deleted),
+            properties = VALUES(properties),
+            update_time = CURRENT_TIMESTAMP
+    </insert>
+
+</mapper>

+ 26 - 0
src/main/resources/mapper/TencentImagesMapper.xml

@@ -0,0 +1,26 @@
+<?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.TencentImagesMapper">
+
+    <resultMap id="BaseResultMap" type="com.moka.gdtauto.entity.TencentImages">
+        <id column="id" property="id"/>
+        <result column="account_id" property="accountId"/>
+        <result column="image_id" property="imageId"/>
+        <result column="image_width" property="imageWidth"/>
+        <result column="image_height" property="imageHeight"/>
+        <result column="image_file_size" property="imageFileSize"/>
+        <result column="image_type" property="imageType"/>
+        <result column="preview_url" property="previewUrl"/>
+        <result column="original_filename" property="originalFilename"/>
+        <result column="has_watermark" property="hasWatermark"/>
+        <result column="watermark_text" property="watermarkText"/>
+        <result column="drama_id" property="dramaId"/>
+        <result column="description" property="description"/>
+        <result column="upload_status" property="uploadStatus"/>
+        <result column="error_message" property="errorMessage"/>
+        <result column="created_by" property="createdBy"/>
+        <result column="created_time" property="createdTime"/>
+        <result column="updated_time" property="updatedTime"/>
+    </resultMap>
+
+</mapper>

+ 20 - 0
src/main/resources/sql/create_tencent_account_table.sql

@@ -0,0 +1,20 @@
+-- 创建腾讯广告账户表
+CREATE TABLE IF NOT EXISTS `tencent_account` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `org_account_id` BIGINT NOT NULL COMMENT '组织账户ID,来自tencent_ads_auth表的accountId',
+    `account_id` BIGINT NOT NULL COMMENT '广告账户ID',
+    `corporation_name` VARCHAR(255) DEFAULT NULL COMMENT '企业名称',
+    `is_bid` TINYINT(1) DEFAULT NULL COMMENT '是否竞价广告账户,1:是 0:否',
+    `is_mp` TINYINT(1) DEFAULT NULL COMMENT '是否微信MP广告账户,1:是 0:否',
+    `is_adx` TINYINT(1) DEFAULT NULL COMMENT '废弃字段,1:是 0:否',
+    `comment_list` TEXT DEFAULT NULL COMMENT '广告主备注列表,JSON字符串格式',
+    `comment_user_id` BIGINT DEFAULT NULL COMMENT '备注列表第一个元素的用户ID',
+    `comment` VARCHAR(500) DEFAULT NULL COMMENT '备注列表第一个元素的备注内容',
+    `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`),
+    UNIQUE KEY `uk_org_account` (`org_account_id`, `account_id`),
+    KEY `idx_org_account_id` (`org_account_id`),
+    KEY `idx_account_id` (`account_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯广告账户表';

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

@@ -0,0 +1,14 @@
+-- 创建腾讯广告短剧资产表
+CREATE TABLE IF NOT EXISTS tencent_asset_drama
+(
+    id                    BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+    marketing_asset_id    BIGINT       NOT NULL COMMENT '产品ID',
+    marketing_asset_name  VARCHAR(256) NULL COMMENT '产品名称',
+    marketing_asset_type  VARCHAR(64)  NULL COMMENT '产品类型',
+    created_time          BIGINT       NULL COMMENT '创建时间,时间戳',
+    is_deleted            TINYINT(1)   NULL COMMENT '是否删除,0:不是,1:是',
+    properties            TEXT         NULL COMMENT '资产属性列表JSON',
+    create_time           DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '记录创建时间',
+    update_time           DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间',
+    UNIQUE KEY uidx_marketing_asset_id (marketing_asset_id)
+) COMMENT '腾讯广告短剧资产表';

+ 10 - 0
src/main/resources/sql/create_tencent_conversion_table.sql

@@ -0,0 +1,10 @@
+-- 创建腾讯广告转化归因表
+CREATE TABLE IF NOT EXISTS `tencent_conversion` (
+    `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `conversion_id` BIGINT NOT NULL COMMENT '转化ID',
+    `conversion_name` VARCHAR(255) DEFAULT NULL COMMENT '转化名称',
+    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_conversion_id` (`conversion_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯广告转化归因表';

+ 26 - 0
src/main/resources/sql/create_tencent_images_table.sql

@@ -0,0 +1,26 @@
+-- 创建腾讯广告图片表
+CREATE TABLE IF NOT EXISTS `tencent_images` (
+    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `account_id` BIGINT(20) NOT NULL COMMENT '广告账户ID',
+    `image_id` VARCHAR(100) DEFAULT NULL COMMENT '腾讯返回的图片ID',
+    `image_width` INT(11) DEFAULT NULL COMMENT '图片宽度(px)',
+    `image_height` INT(11) DEFAULT NULL COMMENT '图片高度(px)',
+    `image_file_size` BIGINT(20) DEFAULT NULL COMMENT '图片大小(B)',
+    `image_type` VARCHAR(50) DEFAULT NULL COMMENT '图片类型',
+    `preview_url` TEXT DEFAULT NULL COMMENT '图片预览地址',
+    `original_filename` VARCHAR(255) DEFAULT NULL COMMENT '原始文件名',
+    `has_watermark` TINYINT(1) DEFAULT 0 COMMENT '是否添加水印(0:否 1:是)',
+    `watermark_text` VARCHAR(100) DEFAULT NULL COMMENT '水印文字(剧名)',
+    `drama_id` BIGINT(20) DEFAULT NULL COMMENT '关联的剧ID',
+    `description` VARCHAR(500) DEFAULT NULL COMMENT '图片描述',
+    `upload_status` VARCHAR(20) DEFAULT 'SUCCESS' COMMENT '上传状态(SUCCESS:成功 FAILED:失败)',
+    `error_message` TEXT DEFAULT NULL COMMENT '错误信息',
+    `created_by` VARCHAR(50) DEFAULT NULL COMMENT '创建人',
+    `created_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `updated_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_account_id` (`account_id`),
+    KEY `idx_image_id` (`image_id`),
+    KEY `idx_drama_id` (`drama_id`),
+    KEY `idx_created_time` (`created_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯广告图片表';

+ 28 - 0
src/main/resources/sql/create_tencent_video_table.sql

@@ -0,0 +1,28 @@
+-- 腾讯广告视频表
+CREATE TABLE IF NOT EXISTS `tencent_video` (
+    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+    `account_id` BIGINT(20) NOT NULL COMMENT '广告账户ID',
+    `video_id` VARCHAR(50) DEFAULT NULL COMMENT '腾讯返回的视频ID',
+    `cover_image_id` VARCHAR(50) DEFAULT NULL COMMENT '腾讯返回的封面图片ID',
+    `video_width` INT(11) DEFAULT NULL COMMENT '视频宽度(px)',
+    `video_height` INT(11) DEFAULT NULL COMMENT '视频高度(px)',
+    `video_file_size` BIGINT(20) DEFAULT NULL COMMENT '视频大小(B)',
+    `video_duration` INT(11) DEFAULT NULL COMMENT '视频时长(秒)',
+    `original_filename` VARCHAR(255) DEFAULT NULL COMMENT '原始文件名',
+    `has_watermark` TINYINT(1) DEFAULT 0 COMMENT '是否添加水印(0:否 1:是)',
+    `watermark_text` VARCHAR(255) DEFAULT NULL COMMENT '水印文字(剧名)',
+    `watermark_duration` INT(11) DEFAULT NULL COMMENT '水印时长(秒),为空表示全程',
+    `drama_id` BIGINT(20) DEFAULT NULL COMMENT '关联的剧ID',
+    `description` VARCHAR(500) DEFAULT NULL COMMENT '视频描述',
+    `upload_status` VARCHAR(20) DEFAULT 'PENDING' COMMENT '上传状态(SUCCESS:成功 FAILED:失败)',
+    `error_message` TEXT DEFAULT NULL COMMENT '错误信息',
+    `created_by` VARCHAR(100) DEFAULT NULL COMMENT '创建人',
+    `created_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `updated_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_account_id` (`account_id`),
+    KEY `idx_video_id` (`video_id`),
+    KEY `idx_drama_id` (`drama_id`),
+    KEY `idx_upload_status` (`upload_status`),
+    KEY `idx_created_time` (`created_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯广告视频表';

+ 201 - 1
src/test/java/com/moka/gdtauto/service/TencentAdsAdgroupCreateTest.java

@@ -3,9 +3,10 @@ package com.moka.gdtauto.service;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.moka.gdtauto.GdtAutoApplication;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
 import com.tencent.ads.model.v3.*;
+import com.tencent.ads.v3.TencentAds;
 
-import org.apache.tomcat.util.http.parser.TE;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -28,9 +29,13 @@ class TencentAdsAdgroupCreateTest {
     @Autowired
     private TencentAdsAdgroupService adgroupService;
 
+    @Autowired
+    private TencentAdsApiClientFactory clientFactory;
+
     private static final Long ORG_ACCOUNT_ID = 33453856L;
     private static final Long TEST_ACCOUNT_ID = 69781948L;
     private static final String TEST_PHONE = "13057411858";
+    private static final Long TEST_ADGROUP_ID = 82845099492L;
 
 
 
@@ -140,6 +145,9 @@ class TencentAdsAdgroupCreateTest {
         WriteTargetingSetting targeting = buildTargetingSetting();
         request.setTargeting(targeting);
 
+        // 创意增强 MAX 开关
+        request.setAutoDerivedCreativeEnabled(true);
+
         return request;
     }
 
@@ -193,4 +201,196 @@ class TencentAdsAdgroupCreateTest {
 
         return targeting;
     }
+
+    @Test
+    @DisplayName("测试创建动态创意")
+    void testCreateDynamicCreative() {
+        System.out.println("\n========================================");
+        System.out.println("开始创建动态创意");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("广告ID: " + TEST_ADGROUP_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+            
+            // 构建动态创意请求
+            DynamicCreativesAddRequest request = buildDynamicCreativeRequest();
+            
+            // 输出请求参数
+            System.out.println("========== 请求参数 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(request));
+            System.out.println("========================================\n");
+
+            // 调用创建动态创意接口
+            System.out.println("正在调用腾讯广告创建动态创意API...");
+            DynamicCreativesAddResponseData response = tencentAds.dynamicCreatives()
+                .dynamicCreativesAdd(request);
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功创建动态创意");
+                System.out.println("📊 动态创意ID: " + response.getDynamicCreativeId());
+                System.out.println("📊 动态创意名称: " + request.getDynamicCreativeName());
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    /**
+     * 构建动态创意请求对象
+     */
+    private DynamicCreativesAddRequest buildDynamicCreativeRequest() {
+        DynamicCreativesAddRequest request = new DynamicCreativesAddRequest();
+        
+        // 必填字段
+        request.setAccountId(TEST_ACCOUNT_ID);
+        request.setAdgroupId(TEST_ADGROUP_ID);
+        request.setDynamicCreativeName("测试动态创意-" + System.currentTimeMillis());
+        
+        // 创意组件
+        request.setDeliveryMode(DeliveryMode.COMPONENT);
+        request.setDynamicCreativeType(DynamicCreativeType.PROGRAM);
+        CreativeComponents components = buildCreativeComponents();
+        request.setCreativeComponents(components);
+        
+        return request;
+    }
+
+    /**
+     * 构建创意组件
+     */
+    private CreativeComponents buildCreativeComponents() {
+        CreativeComponents components = new CreativeComponents();
+        
+        // 标题组件
+        List<TitleComponent> titles = new ArrayList<>();
+        TitleComponent title1 = new TitleComponent();
+        TitleStruct titleValue1 = new TitleStruct();
+        titleValue1.setContent("优质产品推荐");
+        title1.setValue(titleValue1);
+        titles.add(title1);
+        
+        TitleComponent title2 = new TitleComponent();
+        TitleStruct titleValue2 = new TitleStruct();
+        titleValue2.setContent("限时优惠活动");
+        title2.setValue(titleValue2);
+        titles.add(title2);
+        
+        components.setTitle(titles);
+        
+        // 文案
+        List<DescriptionComponent> descriptions = new ArrayList<>();
+        DescriptionComponent desc1 = new DescriptionComponent();
+        DescriptionStruct descValue1 = new DescriptionStruct();
+        descValue1.setContent("高品质产品,值得信赖");
+        desc1.setValue(descValue1);
+        descriptions.add(desc1);
+        
+        DescriptionComponent desc2 = new DescriptionComponent();
+        DescriptionStruct descValue2 = new DescriptionStruct();
+        descValue2.setContent("限时特惠,机会难得");
+        desc2.setValue(descValue2);
+        descriptions.add(desc2);
+        
+        components.setDescription(descriptions);
+        
+        // 图片组件(需要先上传图片获取image_id)
+        List<ImageComponent> images = new ArrayList<>();
+        ImageComponent image1 = new ImageComponent();
+        ImageStruct imageValue1 = new ImageStruct();
+        imageValue1.setImageId("32253396893"); // 需要替换为实际上传的图片ID
+        image1.setValue(imageValue1);
+        images.add(image1);
+        
+        components.setImage(images);
+
+        //品牌形象
+        List<BrandComponent> brands = new ArrayList<>();
+        BrandComponent brand = new BrandComponent();
+        BrandStruct brandValue = new BrandStruct();
+        brandValue.setBrandName("情感美女");
+        brandValue.setBrandImageId("24319699712");
+        brand.setValue(brandValue);
+        brands.add(brand);
+        components.setBrand(brands);
+
+
+        // 落地页
+        // 落地页ID: 1653380852
+        // 落地页ID: 1603275376
+        // 落地页ID: 1612635779
+        // main_jump_info
+        List<JumpinfoComponent> mainJumpInfo = new ArrayList<>();
+
+        JumpinfoComponent jumpinfo = new JumpinfoComponent();
+        JumpinfoStruct jumpinfoValue = new JumpinfoStruct();
+        PageSpec pageSpec = new PageSpec();
+        XjPageSpec xjPageSpec = new XjPageSpec();
+        xjPageSpec.setPageId(1653380852L);
+        pageSpec.setOfficialSpec(xjPageSpec);
+        jumpinfoValue.setPageSpec(pageSpec);
+        jumpinfoValue.setPageType(PageType.OFFICIAL);
+        jumpinfo.setValue(jumpinfoValue);
+        mainJumpInfo.add(jumpinfo);
+
+        //再创建一个pageId = 1603275376
+        JumpinfoComponent jumpinfo2 = new JumpinfoComponent();
+        JumpinfoStruct jumpinfoValue2 = new JumpinfoStruct();
+        PageSpec pageSpec2 = new PageSpec();
+        XjPageSpec xjPageSpec2 = new XjPageSpec();
+        xjPageSpec2.setPageId(1603275376L);
+        pageSpec2.setOfficialSpec(xjPageSpec2);
+        jumpinfoValue2.setPageSpec(pageSpec2);
+        jumpinfoValue2.setPageType(PageType.OFFICIAL);
+        jumpinfo2.setValue(jumpinfoValue2);
+        mainJumpInfo.add(jumpinfo2);
+
+        //再创建一个pageId = 1612635779
+        JumpinfoComponent jumpinfo3 = new JumpinfoComponent();
+        JumpinfoStruct jumpinfoValue3 = new JumpinfoStruct();
+        PageSpec pageSpec3 = new PageSpec();
+        XjPageSpec xjPageSpec3 = new XjPageSpec();
+        xjPageSpec3.setPageId(1612635779L);
+        pageSpec3.setOfficialSpec(xjPageSpec3);
+        jumpinfoValue3.setPageSpec(pageSpec3);
+        jumpinfoValue3.setPageType(PageType.OFFICIAL);
+        jumpinfo3.setValue(jumpinfoValue3);
+        mainJumpInfo.add(jumpinfo3);
+
+        components.setMainJumpInfo(mainJumpInfo);
+
+        //action_button
+        List<ActionButtonComponent> actionButtons = new ArrayList<>();
+        ActionButtonComponent actionButton = new ActionButtonComponent();
+        ActionButtonStruct actionButtonValue = new ActionButtonStruct();
+        actionButtonValue.setButtonText("了解更多");
+        actionButton.setValue(actionButtonValue);
+        actionButtons.add(actionButton);
+        // 查看详情
+        ActionButtonComponent actionButton2 = new ActionButtonComponent();
+        ActionButtonStruct actionButtonValue2 = new ActionButtonStruct();
+        actionButtonValue2.setButtonText("查看详情");
+        actionButton2.setValue(actionButtonValue2);
+        actionButtons.add(actionButton2);
+        components.setActionButton(actionButtons);
+    
+                
+        return components;
+    }
 }

+ 428 - 0
src/test/java/com/moka/gdtauto/service/TencentAdsAdgroupROICreateTest.java

@@ -0,0 +1,428 @@
+package com.moka.gdtauto.service;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.moka.gdtauto.GdtAutoApplication;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.tencent.ads.model.v3.*;
+import com.tencent.ads.v3.TencentAds;
+
+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;
+
+/**
+ * 腾讯广告 用户增长-短剧-注册变现ROI 创建测试
+ * 
+ * @author moka
+ * @since 2026-02-04
+ */
+@SpringBootTest(classes = GdtAutoApplication.class)
+@ActiveProfiles("test")
+class TencentAdsAdgroupROICreateTest {
+
+    @Autowired
+    private TencentAdsAdgroupService adgroupService;
+
+    @Autowired
+    private TencentAdsApiClientFactory clientFactory;
+
+    private static final Long ORG_ACCOUNT_ID = 33453856L;
+    private static final Long TEST_ACCOUNT_ID = 69781948L;
+    private static final String TEST_PHONE = "13057411858";
+    private static final Long TEST_ADGROUP_ID = 82954524190L;
+
+
+    // 出现相识广告报错
+    //{"code":1800428,"code_type":"BusinessException","message":"There is a similar ad [82946601937], 请修改省市区县定向、商圈定向、年龄定向、性别定向 to this ad. Please modify the relevant parameters and resubmit.","message_cn":"该广告存在相似广告[82946601937], 请修改省市区县定向、商圈定向、年龄定向、性别定向 ,请修改相关参数重新提交","trace_id":"efbbd27f9e214449a4ae81235a7dea5f"}
+
+
+    @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(TEST_ACCOUNT_ID);
+            
+            // 输出请求参数
+            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(ORG_ACCOUNT_ID, TEST_PHONE, 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(Long accountId) {
+        AdgroupsAddRequest request = new AdgroupsAddRequest();
+
+        // 基础字段
+        request.setAdgroupName("测试MarketingAPI用户增长ROI_" + System.currentTimeMillis());
+        request.setAccountId(accountId);
+        request.setMarketingGoal(MarketingGoal.USER_GROWTH);
+        request.setMarketingCarrierType(MarketingCarrierType.JUMP_PAGE);
+        request.setMarketingAssetId(421635930L);
+
+        
+        // 投放时间
+        request.setBeginDate("2026-02-06");
+        request.setEndDate("2027-02-06");
+        
+        // 优化目标
+        request.setOptimizationGoal(OptimizationGoal.APP_REGISTER);
+        
+        // 时间序列(全部1表示24小时投放,共7天*48个半小时=336位)
+        request.setTimeSeries("111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111");
+        
+        // 投放站点
+        List<String> siteSet = new ArrayList<>();
+        siteSet.add("SITE_SET_TENCENT_NEWS");
+        siteSet.add("SITE_SET_TENCENT_VIDEO");
+        siteSet.add("SITE_SET_CHANNELS");
+        siteSet.add("SITE_SET_KANDIAN");
+        siteSet.add("SITE_SET_QQ_MUSIC_GAME");
+        siteSet.add("SITE_SET_MOBILE_UNION");
+        siteSet.add("SITE_SET_SEARCH_SCENE");
+        siteSet.add("SITE_SET_MOMENTS");
+        siteSet.add("SITE_SET_WECHAT");
+        siteSet.add("SITE_SET_WECHAT_PLUGIN");
+
+        request.setSiteSet(siteSet);
+        
+        // 出价金额(单位:分)
+        request.setBidAmount(100L);
+        
+        // 日预算(单位:分)
+        request.setDailyBudget(10000L);
+        // 期望ROI
+        request.setDeepConversionWorthRate(0.2);
+    
+        // 出价模式
+        request.setBidMode(BidMode.OCPM);
+
+        // 转化 id
+        request.setConversionId(72627668L);
+        
+        // 自动衍生创意
+        request.setAutoDerivedCreativeEnabled(false);
+        
+        // 出价场景
+        request.setBidScene(BidScene.UNKNOWN);
+        
+        // 定向设置
+        WriteTargetingSetting targeting = buildTargetingSetting();
+        request.setTargeting(targeting);
+
+        // 创意增强 MAX 开关
+        request.setAutoDerivedCreativeEnabled(true);
+
+        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<>();
+        // 29个省份/直辖市
+        regions.add(110000L); // 北京
+        regions.add(120000L); // 天津
+        regions.add(130000L); // 河北
+        regions.add(140000L); // 山西
+        regions.add(150000L); // 内蒙古
+        regions.add(210000L); // 辽宁
+        regions.add(220000L); // 吉林
+        regions.add(230000L); // 黑龙江
+        regions.add(310000L); // 上海
+        regions.add(320000L); // 江苏
+        regions.add(330000L); // 浙江
+        regions.add(340000L); // 安徽
+        regions.add(350000L); // 福建
+        regions.add(360000L); // 江西
+        regions.add(370000L); // 山东
+        regions.add(410000L); // 河南
+        regions.add(420000L); // 湖北
+        regions.add(430000L); // 湖南
+        regions.add(440000L); // 广东
+        regions.add(450000L); // 广西
+        regions.add(460000L); // 海南
+        regions.add(500000L); // 重庆
+        regions.add(510000L); // 四川
+        regions.add(520000L); // 贵州
+        regions.add(530000L); // 云南
+        regions.add(610000L); // 陕西
+        regions.add(620000L); // 甘肃
+        regions.add(630000L); // 青海
+        regions.add(640000L); // 宁夏
+        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(40L);
+        ageRange.setMax(63L);
+        age.add(ageRange);
+        targeting.setAge(age);
+
+        // 排除自定义人群
+        List<Long> excludedCustomAudience = new ArrayList<>();
+        excludedCustomAudience.add(44106830L);
+        excludedCustomAudience.add(44107982L);
+        excludedCustomAudience.add(43712084L);
+        targeting.setExcludedCustomAudience(excludedCustomAudience);
+
+        return targeting;
+    }
+
+    @Test
+    @DisplayName("测试创建动态创意")
+    void testCreateDynamicCreative() {
+        System.out.println("\n========================================");
+        System.out.println("开始创建动态创意");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("广告ID: " + TEST_ADGROUP_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+            
+            // 构建动态创意请求
+            DynamicCreativesAddRequest request = buildDynamicCreativeRequest();
+            
+            // 输出请求参数
+            System.out.println("========== 请求参数 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(request));
+            System.out.println("========================================\n");
+
+            // 调用创建动态创意接口
+            System.out.println("正在调用腾讯广告创建动态创意API...");
+            DynamicCreativesAddResponseData response = tencentAds.dynamicCreatives()
+                .dynamicCreativesAdd(request);
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功创建动态创意");
+                System.out.println("📊 动态创意ID: " + response.getDynamicCreativeId());
+                System.out.println("📊 动态创意名称: " + request.getDynamicCreativeName());
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    /**
+     * 构建动态创意请求对象
+     */
+    private DynamicCreativesAddRequest buildDynamicCreativeRequest() {
+        DynamicCreativesAddRequest request = new DynamicCreativesAddRequest();
+        
+        // 必填字段
+        request.setAccountId(TEST_ACCOUNT_ID);
+        request.setAdgroupId(TEST_ADGROUP_ID);
+        request.setDynamicCreativeName("测试动态创意-" + System.currentTimeMillis());
+        
+        // 创意组件
+        request.setDeliveryMode(DeliveryMode.COMPONENT);
+        request.setDynamicCreativeType(DynamicCreativeType.PROGRAM);
+        CreativeComponents components = buildCreativeComponents();
+        request.setCreativeComponents(components);
+        
+        return request;
+    }
+
+    /**
+     * 构建创意组件
+     */
+    private CreativeComponents buildCreativeComponents() {
+        CreativeComponents components = new CreativeComponents();
+        
+        // 标题组件
+        List<TitleComponent> titles = new ArrayList<>();
+        TitleComponent title1 = new TitleComponent();
+        TitleStruct titleValue1 = new TitleStruct();
+        titleValue1.setContent("点击看更多情感美文");
+        title1.setValue(titleValue1);
+        titles.add(title1);
+        
+        TitleComponent title2 = new TitleComponent();
+        TitleStruct titleValue2 = new TitleStruct();
+        titleValue2.setContent("高品质产品,值得信赖");
+        title2.setValue(titleValue2);
+        titles.add(title2);
+        
+        components.setTitle(titles);
+        
+        // 文案
+        List<DescriptionComponent> descriptions = new ArrayList<>();
+        DescriptionComponent desc1 = new DescriptionComponent();
+        DescriptionStruct descValue1 = new DescriptionStruct();
+        descValue1.setContent("点击看更多情感美文");
+        desc1.setValue(descValue1);
+        descriptions.add(desc1);
+        
+        DescriptionComponent desc2 = new DescriptionComponent();
+        DescriptionStruct descValue2 = new DescriptionStruct();
+        descValue2.setContent("点击看更多");
+        desc2.setValue(descValue2);
+        descriptions.add(desc2);
+        
+        components.setDescription(descriptions);
+        
+        // 图片组件(需要先上传图片获取image_id)
+        List<ImageComponent> images = new ArrayList<>();
+        ImageComponent image1 = new ImageComponent();
+        ImageStruct imageValue1 = new ImageStruct();
+        imageValue1.setImageId("32248211296"); // 需要替换为实际上传的图片ID
+        image1.setValue(imageValue1);
+        images.add(image1);
+        
+        components.setImage(images);
+
+        //品牌形象
+        List<BrandComponent> brands = new ArrayList<>();
+        BrandComponent brand = new BrandComponent();
+        BrandStruct brandValue = new BrandStruct();
+        brandValue.setBrandName("情感美女");
+        brandValue.setBrandImageId("24319699712");
+        brand.setValue(brandValue);
+        brands.add(brand);
+        components.setBrand(brands);
+
+
+        // 当前所选的落地页无法在当前的投放场景使用, 缺少必要组件:[引用外层素材组件]
+        // 落地页
+        // 落地页ID: 2281329341
+        // 落地页ID: 2281321715
+        // 落地页ID: 2277183863
+        // main_jump_info
+        List<JumpinfoComponent> mainJumpInfo = new ArrayList<>();
+
+        JumpinfoComponent jumpinfo = new JumpinfoComponent();
+        JumpinfoStruct jumpinfoValue = new JumpinfoStruct();
+        PageSpec pageSpec = new PageSpec();
+        XjPageSpec xjPageSpec = new XjPageSpec();
+        xjPageSpec.setPageId( 2281329341L);
+        pageSpec.setOfficialSpec(xjPageSpec);
+        jumpinfoValue.setPageSpec(pageSpec);
+        jumpinfoValue.setPageType(PageType.OFFICIAL);
+        jumpinfo.setValue(jumpinfoValue);
+        mainJumpInfo.add(jumpinfo);
+
+        //再创建一个pageId = 2281321715
+        JumpinfoComponent jumpinfo2 = new JumpinfoComponent();
+        JumpinfoStruct jumpinfoValue2 = new JumpinfoStruct();
+        PageSpec pageSpec2 = new PageSpec();
+        XjPageSpec xjPageSpec2 = new XjPageSpec();
+        xjPageSpec2.setPageId(2281321715L);
+        pageSpec2.setOfficialSpec(xjPageSpec2);
+        jumpinfoValue2.setPageSpec(pageSpec2);
+        jumpinfoValue2.setPageType(PageType.OFFICIAL);
+        jumpinfo2.setValue(jumpinfoValue2);
+        mainJumpInfo.add(jumpinfo2);
+
+        //再创建一个pageId = 2277183863
+        JumpinfoComponent jumpinfo3 = new JumpinfoComponent();
+        JumpinfoStruct jumpinfoValue3 = new JumpinfoStruct();
+        PageSpec pageSpec3 = new PageSpec();
+        XjPageSpec xjPageSpec3 = new XjPageSpec();
+        xjPageSpec3.setPageId(2277183863L);
+        pageSpec3.setOfficialSpec(xjPageSpec3);
+        jumpinfoValue3.setPageSpec(pageSpec3);
+        jumpinfoValue3.setPageType(PageType.OFFICIAL);
+        jumpinfo3.setValue(jumpinfoValue3);
+        mainJumpInfo.add(jumpinfo3);
+
+        components.setMainJumpInfo(mainJumpInfo);
+
+        // 几个落地页就需要几个按钮
+        //  button_text of all button_text components must be same
+        // 动按钮组件中的所有按钮文案必须保持一致
+        //action_button
+        List<ActionButtonComponent> actionButtons = new ArrayList<>();
+        ActionButtonComponent actionButton = new ActionButtonComponent();
+        ActionButtonStruct actionButtonValue = new ActionButtonStruct();
+        actionButtonValue.setButtonText("查看详情");
+        actionButton.setValue(actionButtonValue);
+        actionButtons.add(actionButton);
+        // 查看详情
+        ActionButtonComponent actionButton2 = new ActionButtonComponent();
+        ActionButtonStruct actionButtonValue2 = new ActionButtonStruct();
+        actionButtonValue2.setButtonText("查看详情");
+        actionButton2.setValue(actionButtonValue2);
+        actionButtons.add(actionButton2);
+        components.setActionButton(actionButtons);
+        // 点击查看
+        ActionButtonComponent actionButton3 = new ActionButtonComponent();
+        ActionButtonStruct actionButtonValue3 = new ActionButtonStruct();
+        actionButtonValue3.setButtonText("查看详情");
+        actionButton3.setValue(actionButtonValue3);
+        actionButtons.add(actionButton3);
+    
+                
+        return components;
+    }
+}

+ 208 - 0
src/test/java/com/moka/gdtauto/service/TencentAssetDramaTest.java

@@ -0,0 +1,208 @@
+package com.moka.gdtauto.service;
+
+import com.alibaba.fastjson2.JSON;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.moka.gdtauto.GdtAutoApplication;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.tencent.ads.model.v3.MarketingTargetAssetsGetResponseData;
+import com.tencent.ads.model.v3.MarketingTargetType;
+import com.tencent.ads.v3.TencentAds;
+
+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;
+
+/**
+ * 腾讯广告短剧资产测试
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@SpringBootTest(classes = GdtAutoApplication.class)
+@ActiveProfiles("test")
+class TencentAssetDramaTest {
+
+    @Autowired
+    private TencentAssetDramaService assetDramaService;
+
+    @Autowired
+    private TencentAdsApiClientFactory clientFactory;
+
+    private static final Long ORG_ACCOUNT_ID = 33453856L;
+    private static final Long TEST_ACCOUNT_ID = 69781948L;
+    private static final String TEST_PHONE = "13057411858";
+
+    @Test
+    @DisplayName("测试获取短剧资产列表")
+    void testGetMarketingAssets() {
+        System.out.println("\n========================================");
+        System.out.println("开始获取短剧资产列表");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("资产类型: MARKETING_TARGET_TYPE_SHORT_DRAMA");
+        System.out.println("========================================\n");
+
+        try {
+            // 调用API获取短剧资产列表
+            System.out.println("正在调用腾讯广告API...");
+            MarketingTargetAssetsGetResponseData response = assetDramaService.getMarketingAssets(
+                ORG_ACCOUNT_ID,
+                TEST_PHONE,
+                TEST_ACCOUNT_ID,
+                null,  // 无过滤条件
+                1L,    // 第1页
+                10L    // 每页10条
+            );
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功获取短剧资产列表");
+                
+                if (response.getPageInfo() != null) {
+                    System.out.println("📊 总数: " + response.getPageInfo().getTotalNumber());
+                    System.out.println("📊 总页数: " + response.getPageInfo().getTotalPage());
+                    System.out.println("📊 当前页: " + response.getPageInfo().getPage());
+                    System.out.println("📊 每页大小: " + response.getPageInfo().getPageSize());
+                }
+                
+                if (response.getList() != null && !response.getList().isEmpty()) {
+                    System.out.println("\n📋 短剧资产列表:");
+                    response.getList().forEach(asset -> {
+                        System.out.println("  - ID: " + asset.getMarketingAssetId() + 
+                                         ", 名称: " + asset.getMarketingAssetName() +
+                                         ", 类型: " + asset.getMarketingAssetType());
+                        
+                        // 输出properties详情
+                        if (asset.getProperties() != null && !asset.getProperties().isEmpty()) {
+                            System.out.println("    属性列表:");
+                            asset.getProperties().forEach(prop -> {
+                                System.out.println("      * " + prop.getPropertyCn() + 
+                                                 " (" + prop.getPropertyName() + "): " + 
+                                                 prop.getPropertyValue());
+                            });
+                        }
+                    });
+                } else {
+                    System.out.println("⚠️  未找到短剧资产");
+                }
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    @Test
+    @DisplayName("测试直接使用SDK获取短剧资产")
+    void testGetMarketingAssetsBySDK() {
+        System.out.println("\n========================================");
+        System.out.println("开始测试直接使用SDK获取短剧资产");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+            
+            // 调用SDK获取短剧资产列表
+            System.out.println("正在调用腾讯广告SDK API...");
+            MarketingTargetAssetsGetResponseData response = tencentAds.marketingTargetAssets()
+                .marketingTargetAssetsGet(
+                    "MARKETING_TARGET_TYPE_SHORT_DRAMA",  // marketing_target_type
+                    TEST_ACCOUNT_ID,
+                    null,  // filtering
+                    1L,    // page
+                    10L,   // page_size
+                    null,  // organization_id
+                    null   // fields
+                );
+
+            // 输出响应结果
+            System.out.println("\n========== SDK API返回结果 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null && response.getList() != null) {
+                System.out.println("✅ 成功获取短剧资产列表");
+                System.out.println("📊 资产数量: " + response.getList().size());
+                
+                // 详细输出每个资产的信息
+                response.getList().forEach(asset -> {
+                    System.out.println("\n【短剧资产】");
+                    System.out.println("  资产ID: " + asset.getMarketingAssetId());
+                    System.out.println("  资产名称: " + asset.getMarketingAssetName());
+                    System.out.println("  资产类型: " + asset.getMarketingAssetType());
+                    System.out.println("  创建时间: " + asset.getCreatedTime());
+                    // SDK中MarketingAssetStruct没有isDeleted字段
+                    // System.out.println("  是否删除: " + asset.getIsDeleted());
+                    
+                    if (asset.getProperties() != null) {
+                        System.out.println("  属性列表:");
+                        asset.getProperties().forEach(prop -> {
+                            System.out.println("    - " + prop.getPropertyCn() + 
+                                             " [" + prop.getPropertyName() + "]");
+                            System.out.println("      类型: " + prop.getPropertyClass());
+                            System.out.println("      值: " + prop.getPropertyValue());
+                        });
+                    }
+                });
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ SDK调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    @Test
+    @DisplayName("测试同步短剧资产到数据库")
+    void testSyncMarketingAssets() {
+        System.out.println("\n========================================");
+        System.out.println("开始同步短剧资产到数据库");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 调用同步方法
+            System.out.println("正在同步短剧资产...");
+            int count = assetDramaService.syncMarketingAssets(
+                ORG_ACCOUNT_ID,
+                TEST_PHONE,
+                TEST_ACCOUNT_ID
+            );
+
+            // 输出结果
+            System.out.println("\n========== 同步结果 ==========");
+            System.out.println("✅ 同步完成");
+            System.out.println("📊 影响行数: " + count);
+            System.out.println("========================================\n");
+
+        } catch (Exception e) {
+            System.err.println("\n❌ 同步失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+}
+

+ 133 - 0
src/test/java/com/moka/gdtauto/service/TencentConversionGetTest.java

@@ -0,0 +1,133 @@
+package com.moka.gdtauto.service;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.moka.gdtauto.GdtAutoApplication;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.tencent.ads.model.v3.*;
+import com.tencent.ads.v3.TencentAds;
+
+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-06
+ */
+@SpringBootTest(classes = GdtAutoApplication.class)
+@ActiveProfiles("test")
+class TencentConversionGetTest {
+
+    @Autowired
+    private TencentAdsApiClientFactory clientFactory;
+
+    private static final Long ORG_ACCOUNT_ID = 33453856L;
+    private static final Long TEST_ACCOUNT_ID = 69781948L;
+    private static final String TEST_PHONE = "13057411858";
+
+    @Test
+    @DisplayName("测试查询转化归因-按优化目标和营销目标过滤")
+    void testGetConversions() {
+        System.out.println("\n========================================");
+        System.out.println("开始查询转化归因");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+            
+            // 构建过滤条件
+            List<FilteringStruct> filtering = buildFiltering();
+            
+            // 输出请求参数
+            System.out.println("========== 请求参数 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println("Account ID: " + TEST_ACCOUNT_ID);
+            System.out.println("Filtering: " + gson.toJson(filtering));
+            System.out.println("========================================\n");
+            List<String> fields = List.of("conversion_id", "conversion_name", "optimization_goal");
+            // 调用查询转化归因接口
+            System.out.println("正在调用腾讯广告查询转化归因API...");
+            ConversionsGetResponseData response = tencentAds.conversions()
+                .conversionsGet(
+                    TEST_ACCOUNT_ID,
+                    filtering,
+                    null,  // page
+                    null,  // page_size
+                    fields   // fields
+                );
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null && response.getList() != null) {
+                System.out.println("✅ 查询成功");
+                System.out.println("📊 转化归因数量: " + response.getList().size());
+                System.out.println("\n转化归因列表:");
+                for (ConversionsGetListStruct conversion : response.getList()) {
+                    System.out.println("  - 转化ID: " + conversion.getConversionId());
+                    System.out.println("    转化名称: " + conversion.getConversionName());
+                    System.out.println("    优化目标: " + conversion.getOptimizationGoal());
+                    System.out.println();
+                }
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    /**
+     * 构建过滤条件
+     */
+    private List<FilteringStruct> buildFiltering() {
+        List<FilteringStruct> filtering = new ArrayList<>();
+        
+        // 过滤条件1:优化目标 = OPTIMIZATIONGOAL_APP_REGISTER
+        FilteringStruct filter1 = new FilteringStruct();
+        filter1.setField("optimization_goal");
+        filter1.setOperator(FilterOperator.EQUALS);
+        List<String> values1 = new ArrayList<>();
+        values1.add("OPTIMIZATIONGOAL_APP_REGISTER");
+        filter1.setValues(values1);
+        filtering.add(filter1);
+        
+        // 过滤条件2:营销目标 = MARKETING_GOAL_USER_GROWTH
+        FilteringStruct filter2 = new FilteringStruct();
+        filter2.setField("marketing_goal");
+        filter2.setOperator(FilterOperator.EQUALS);
+        List<String> values2 = new ArrayList<>();
+        values2.add("MARKETING_GOAL_USER_GROWTH");
+        filter2.setValues(values2);
+        filtering.add(filter2);
+        
+        // 过滤条件3:营销资产类型 = MARKETING_TARGET_TYPE_SHORT_DRAMA
+        FilteringStruct filter3 = new FilteringStruct();
+        filter3.setField("marketing_target_type");
+        filter3.setOperator(FilterOperator.EQUALS);
+        List<String> values3 = new ArrayList<>();
+        values3.add("MARKETING_TARGET_TYPE_SHORT_DRAMA");
+        filter3.setValues(values3);
+        filtering.add(filter3);
+        
+        return filtering;
+    }
+}

+ 315 - 0
src/test/java/com/moka/gdtauto/service/TencentImagesAddTest.java

@@ -0,0 +1,315 @@
+package com.moka.gdtauto.service;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.moka.gdtauto.GdtAutoApplication;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.tencent.ads.model.v3.ImagesAddResponseData;
+import com.tencent.ads.v3.TencentAds;
+
+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.io.File;
+import java.io.FileInputStream;
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.security.MessageDigest;
+import java.util.Base64;
+
+/**
+ * 腾讯广告图片上传测试
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@SpringBootTest(classes = GdtAutoApplication.class)
+@ActiveProfiles("test")
+class TencentImagesAddTest {
+
+    @Autowired
+    private TencentAdsApiClientFactory clientFactory;
+
+    private static final Long ORG_ACCOUNT_ID = 33453856L;
+    private static final Long TEST_ACCOUNT_ID = 69781948L;
+    private static final String TEST_PHONE = "13057411858";
+
+    @Test
+    @DisplayName("测试上传图片文件-使用文件方式")
+    void testUploadImageFile() {
+        System.out.println("\n========================================");
+        System.out.println("开始上传图片文件");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+
+            // 图片文件路径(请替换为实际图片路径)
+            String imagePath = "/Users/pudongliang/moka_workspace/gdt-auto/test.jpeg";
+            File imageFile = new File(imagePath);
+            
+            if (!imageFile.exists()) {
+                System.err.println("❌ 图片文件不存在: " + imagePath);
+                System.err.println("请修改 imagePath 为实际的图片文件路径");
+                return;
+            }
+
+            // 计算图片MD5签名
+            String signature = calculateMD5(imageFile);
+            System.out.println("图片MD5签名: " + signature);
+
+            String description = "测试上传图片-" + System.currentTimeMillis();
+            
+            // 输出请求参数
+            System.out.println("\n========== 请求参数 ==========");
+            System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+            System.out.println("上传方式: UPLOAD_TYPE_FILE");
+            System.out.println("签名: " + signature);
+            System.out.println("文件路径: " + imagePath);
+            System.out.println("描述: " + description);
+            System.out.println("========================================\n");
+
+            // 调用上传接口
+            System.out.println("正在调用腾讯广告上传图片API...");
+            ImagesAddResponseData response = tencentAds.images()
+                .imagesAdd(
+                    "UPLOAD_TYPE_FILE",  // uploadType
+                    signature,            // signature
+                    TEST_ACCOUNT_ID,      // accountId
+                    null,                 // organizationId
+                    imageFile,            // file
+                    null,                 // bytes
+                    null,                 // imageUsage
+                    description,          // description
+                    null,                 // resizeWidth
+                    null,                 // resizeHeight
+                    null                  // resizeFileSize
+                );
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功上传图片");
+                System.out.println("📊 图片ID: " + response.getImageId());
+                System.out.println("📊 图片宽度: " + response.getImageWidth() + "px");
+                System.out.println("📊 图片高度: " + response.getImageHeight() + "px");
+                System.out.println("📊 图片大小: " + response.getImageFileSize() + "B");
+                System.out.println("📊 图片类型: " + response.getImageType());
+                System.out.println("📊 预览地址: " + response.getPreviewUrl());
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    @Test
+    @DisplayName("测试上传图片文件-使用Base64方式")
+    void testUploadImageBytes() {
+        System.out.println("\n========================================");
+        System.out.println("开始上传图片文件(Base64)");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+
+            // 图片文件路径(请替换为实际图片路径)
+            String imagePath = "/path/to/your/image.jpg";
+            File imageFile = new File(imagePath);
+            
+            if (!imageFile.exists()) {
+                System.err.println("❌ 图片文件不存在: " + imagePath);
+                System.err.println("请修改 imagePath 为实际的图片文件路径");
+                return;
+            }
+
+            // 读取图片并转Base64
+            byte[] imageBytes = Files.readAllBytes(imageFile.toPath());
+            String base64Image = Base64.getEncoder().encodeToString(imageBytes);
+            
+            // 计算图片MD5签名
+            String signature = calculateMD5(imageFile);
+            System.out.println("图片MD5签名: " + signature);
+            System.out.println("图片Base64长度: " + base64Image.length());
+
+            String description = "测试上传图片Base64-" + System.currentTimeMillis();
+            
+            // 输出请求参数
+            System.out.println("\n========== 请求参数 ==========");
+            System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+            System.out.println("上传方式: UPLOAD_TYPE_BYTES");
+            System.out.println("签名: " + signature);
+            System.out.println("Base64长度: " + base64Image.length());
+            System.out.println("描述: " + description);
+            System.out.println("========================================\n");
+
+            // 调用上传接口
+            System.out.println("正在调用腾讯广告上传图片API...");
+            ImagesAddResponseData response = tencentAds.images()
+                .imagesAdd(
+                    "UPLOAD_TYPE_BYTES", // uploadType
+                    signature,            // signature
+                    TEST_ACCOUNT_ID,      // accountId
+                    null,                 // organizationId
+                    null,                 // file
+                    base64Image,          // bytes
+                    null,                 // imageUsage
+                    description,          // description
+                    null,                 // resizeWidth
+                    null,                 // resizeHeight
+                    null                  // resizeFileSize
+                );
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功上传图片");
+                System.out.println("📊 图片ID: " + response.getImageId());
+                System.out.println("📊 图片宽度: " + response.getImageWidth() + "px");
+                System.out.println("📊 图片高度: " + response.getImageHeight() + "px");
+                System.out.println("📊 图片大小: " + response.getImageFileSize() + "B");
+                System.out.println("📊 图片类型: " + response.getImageType());
+                System.out.println("📊 预览地址: " + response.getPreviewUrl());
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    @Test
+    @DisplayName("测试上传图片并指定尺寸")
+    void testUploadImageWithResize() {
+        System.out.println("\n========================================");
+        System.out.println("开始上传图片文件(指定尺寸)");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+
+            // 图片文件路径(请替换为实际图片路径)
+            String imagePath = "/path/to/your/image.jpg";
+            File imageFile = new File(imagePath);
+            
+            if (!imageFile.exists()) {
+                System.err.println("❌ 图片文件不存在: " + imagePath);
+                System.err.println("请修改 imagePath 为实际的图片文件路径");
+                return;
+            }
+
+            // 计算图片MD5签名
+            String signature = calculateMD5(imageFile);
+            String description = "测试上传图片并调整尺寸-" + System.currentTimeMillis();
+            Long resizeWidth = 800L;
+            Long resizeHeight = 600L;
+            
+            // 输出请求参数
+            System.out.println("\n========== 请求参数 ==========");
+            System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+            System.out.println("上传方式: UPLOAD_TYPE_FILE");
+            System.out.println("签名: " + signature);
+            System.out.println("文件路径: " + imagePath);
+            System.out.println("目标宽度: " + resizeWidth + "px");
+            System.out.println("目标高度: " + resizeHeight + "px");
+            System.out.println("图片用途: IMAGE_USAGE_DEFAULT");
+            System.out.println("描述: " + description);
+            System.out.println("========================================\n");
+
+            // 调用上传接口
+            System.out.println("正在调用腾讯广告上传图片API...");
+            ImagesAddResponseData response = tencentAds.images()
+                .imagesAdd(
+                    "UPLOAD_TYPE_FILE",      // uploadType
+                    signature,                // signature
+                    TEST_ACCOUNT_ID,          // accountId
+                    null,                     // organizationId
+                    imageFile,                // file
+                    null,                     // bytes
+                    "IMAGE_USAGE_DEFAULT",   // imageUsage
+                    description,              // description
+                    resizeWidth,              // resizeWidth
+                    resizeHeight,             // resizeHeight
+                    null                      // resizeFileSize
+                );
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功上传图片");
+                System.out.println("📊 图片ID: " + response.getImageId());
+                System.out.println("📊 图片宽度: " + response.getImageWidth() + "px");
+                System.out.println("📊 图片高度: " + response.getImageHeight() + "px");
+                System.out.println("📊 图片大小: " + response.getImageFileSize() + "B");
+                System.out.println("📊 图片类型: " + response.getImageType());
+                System.out.println("📊 预览地址: " + response.getPreviewUrl());
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    /**
+     * 计算文件的MD5签名
+     */
+    private String calculateMD5(File file) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("MD5");
+        try (FileInputStream fis = new FileInputStream(file)) {
+            byte[] buffer = new byte[8192];
+            int bytesRead;
+            while ((bytesRead = fis.read(buffer)) != -1) {
+                md.update(buffer, 0, bytesRead);
+            }
+        }
+        byte[] digest = md.digest();
+        BigInteger bigInt = new BigInteger(1, digest);
+        String hashtext = bigInt.toString(16);
+        // 补齐前导0
+        while (hashtext.length() < 32) {
+            hashtext = "0" + hashtext;
+        }
+        return hashtext;
+    }
+}

+ 291 - 0
src/test/java/com/moka/gdtauto/service/TencentVideoAddTest.java

@@ -0,0 +1,291 @@
+package com.moka.gdtauto.service;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.moka.gdtauto.GdtAutoApplication;
+import com.moka.gdtauto.client.TencentAdsApiClientFactory;
+import com.tencent.ads.model.v3.VideosAddResponseData;
+import com.tencent.ads.v3.TencentAds;
+
+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.io.File;
+import java.io.FileInputStream;
+import java.math.BigInteger;
+import java.security.MessageDigest;
+
+/**
+ * 腾讯广告视频上传测试
+ * 
+ * @author moka
+ * @since 2026-02-06
+ */
+@SpringBootTest(classes = GdtAutoApplication.class)
+@ActiveProfiles("test")
+class TencentVideoAddTest {
+
+    @Autowired
+    private TencentAdsApiClientFactory clientFactory;
+
+    private static final Long ORG_ACCOUNT_ID = 33453856L;
+    private static final Long TEST_ACCOUNT_ID = 69781948L;
+    private static final String TEST_PHONE = "13057411858";
+
+    @Test
+    @DisplayName("测试上传视频文件")
+    void testUploadVideoFile() {
+        System.out.println("\n========================================");
+        System.out.println("开始上传视频文件");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+
+            // 视频文件路径(请替换为实际视频路径)
+            String videoPath = "/Users/pudongliang/moka_workspace/gdt-auto/test.mp4";
+            File videoFile = new File(videoPath);
+            
+            if (!videoFile.exists()) {
+                System.err.println("❌ 视频文件不存在: " + videoPath);
+                System.err.println("请修改 videoPath 为实际的视频文件路径");
+                System.err.println("支持的格式: mp4、mov、avi,最大支持 100M");
+                return;
+            }
+
+            // 检查文件大小
+            long fileSizeInMB = videoFile.length() / (1024 * 1024);
+            if (fileSizeInMB > 100) {
+                System.err.println("❌ 视频文件过大: " + fileSizeInMB + "MB,超过 100MB 限制");
+                return;
+            }
+
+            // 计算视频MD5签名
+            String signature = calculateMD5(videoFile);
+            System.out.println("视频MD5签名: " + signature);
+            System.out.println("视频文件大小: " + fileSizeInMB + "MB");
+
+            String description = "测试上传视频-" + System.currentTimeMillis();
+            
+            // 输出请求参数
+            System.out.println("\n========== 请求参数 ==========");
+            System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+            System.out.println("签名: " + signature);
+            System.out.println("文件路径: " + videoPath);
+            System.out.println("文件大小: " + fileSizeInMB + "MB");
+            System.out.println("描述: " + description);
+            System.out.println("========================================\n");
+
+            // 调用上传接口
+            System.out.println("正在调用腾讯广告上传视频API...");
+            VideosAddResponseData response = tencentAds.videos()
+                .videosAdd(
+                    videoFile,           // videoFile
+                    signature,           // signature
+                    TEST_ACCOUNT_ID,     // accountId
+                    null,                // organizationId
+                    description,         // description
+                    null                 // adcreativeTemplateId
+                );
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功上传视频");
+                System.out.println("📊 视频ID: " + response.getVideoId());
+                if (response.getCoverImageId() != null) {
+                    System.out.println("📊 封面图片ID: " + response.getCoverImageId());
+                }
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    @Test
+    @DisplayName("测试上传视频文件-指定创意形式")
+    void testUploadVideoWithTemplate() {
+        System.out.println("\n========================================");
+        System.out.println("开始上传视频文件(指定创意形式)");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+
+            // 视频文件路径(请替换为实际视频路径)
+            String videoPath = "/path/to/your/video.mp4";
+            File videoFile = new File(videoPath);
+            
+            if (!videoFile.exists()) {
+                System.err.println("❌ 视频文件不存在: " + videoPath);
+                System.err.println("请修改 videoPath 为实际的视频文件路径");
+                return;
+            }
+
+            // 计算视频MD5签名
+            String signature = calculateMD5(videoFile);
+            String description = "测试上传视频-指定创意形式-" + System.currentTimeMillis();
+            
+            // 创意形式ID(请根据实际情况替换)
+            Long adcreativeTemplateId = 1234L;
+            
+            // 输出请求参数
+            System.out.println("\n========== 请求参数 ==========");
+            System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+            System.out.println("签名: " + signature);
+            System.out.println("文件路径: " + videoPath);
+            System.out.println("描述: " + description);
+            System.out.println("创意形式ID: " + adcreativeTemplateId);
+            System.out.println("========================================\n");
+
+            // 调用上传接口
+            System.out.println("正在调用腾讯广告上传视频API...");
+            VideosAddResponseData response = tencentAds.videos()
+                .videosAdd(
+                    videoFile,              // videoFile
+                    signature,              // signature
+                    TEST_ACCOUNT_ID,        // accountId
+                    null,                   // organizationId
+                    description,            // description
+                    adcreativeTemplateId    // adcreativeTemplateId
+                );
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功上传视频");
+                System.out.println("📊 视频ID: " + response.getVideoId());
+                if (response.getCoverImageId() != null) {
+                    System.out.println("📊 封面图片ID: " + response.getCoverImageId());
+                }
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    @Test
+    @DisplayName("测试上传视频文件-使用组织ID")
+    void testUploadVideoWithOrganizationId() {
+        System.out.println("\n========================================");
+        System.out.println("开始上传视频文件(使用组织ID)");
+        System.out.println("组织ID: " + ORG_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(ORG_ACCOUNT_ID, TEST_PHONE);
+            tencentAds.setDebug(true);
+
+            // 视频文件路径(请替换为实际视频路径)
+            String videoPath = "/path/to/your/video.mp4";
+            File videoFile = new File(videoPath);
+            
+            if (!videoFile.exists()) {
+                System.err.println("❌ 视频文件不存在: " + videoPath);
+                System.err.println("请修改 videoPath 为实际的视频文件路径");
+                return;
+            }
+
+            // 计算视频MD5签名
+            String signature = calculateMD5(videoFile);
+            String description = "测试上传视频-组织ID-" + System.currentTimeMillis();
+            
+            // 输出请求参数
+            System.out.println("\n========== 请求参数 ==========");
+            System.out.println("组织ID: " + ORG_ACCOUNT_ID);
+            System.out.println("签名: " + signature);
+            System.out.println("文件路径: " + videoPath);
+            System.out.println("描述: " + description);
+            System.out.println("========================================\n");
+
+            // 调用上传接口
+            System.out.println("正在调用腾讯广告上传视频API...");
+            VideosAddResponseData response = tencentAds.videos()
+                .videosAdd(
+                    videoFile,           // videoFile
+                    signature,           // signature
+                    null,                // accountId
+                    ORG_ACCOUNT_ID,      // organizationId
+                    description,         // description
+                    null                 // adcreativeTemplateId
+                );
+
+            // 输出响应结果
+            System.out.println("\n========== API返回结果 ==========");
+            Gson gson = new GsonBuilder().setPrettyPrinting().create();
+            System.out.println(gson.toJson(response));
+            System.out.println("========================================\n");
+
+            // 输出摘要信息
+            if (response != null) {
+                System.out.println("✅ 成功上传视频");
+                System.out.println("📊 视频ID: " + response.getVideoId());
+                if (response.getCoverImageId() != null) {
+                    System.out.println("📊 封面图片ID: " + response.getCoverImageId());
+                }
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+
+    /**
+     * 计算文件的MD5签名
+     */
+    private String calculateMD5(File file) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("MD5");
+        try (FileInputStream fis = new FileInputStream(file)) {
+            byte[] buffer = new byte[8192];
+            int bytesRead;
+            while ((bytesRead = fis.read(buffer)) != -1) {
+                md.update(buffer, 0, bytesRead);
+            }
+        }
+        byte[] digest = md.digest();
+        BigInteger bigInt = new BigInteger(1, digest);
+        String hashtext = bigInt.toString(16);
+        // 补齐前导0
+        while (hashtext.length() < 32) {
+            hashtext = "0" + hashtext;
+        }
+        return hashtext;
+    }
+}

+ 99 - 0
src/test/java/com/moka/gdtauto/util/ImageWatermarkSimpleTest.java

@@ -0,0 +1,99 @@
+package com.moka.gdtauto.util;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * 图片水印简单测试程序
+ * 可以直接运行main方法进行测试
+ */
+public class ImageWatermarkSimpleTest {
+
+    public static void main(String[] args) {
+        try {
+            String outputDir = "target/test-output/watermark";
+            File dir = new File(outputDir);
+            if (!dir.exists()) {
+                dir.mkdirs();
+            }
+
+            // 创建测试图片
+            String inputPath = outputDir + "/test-input.jpg";
+            createTestImage(inputPath, 1920, 1080);
+            System.out.println("✓ 创建测试图片成功: " + inputPath);
+
+            // 测试1: 基本水印功能
+            String outputPath1 = outputDir + "/output-basic.jpg";
+            ImageWatermarkUtil.addWatermark(inputPath, outputPath1, "《绝世龙王》");
+            System.out.println("✓ 测试1通过: 基本水印功能 -> " + outputPath1);
+
+            // 测试2: 自定义位置和字体
+            String outputPath2 = outputDir + "/output-custom.jpg";
+            ImageWatermarkUtil.addWatermark(inputPath, outputPath2, "《绝世龙王》", 60, 100, 100);
+            System.out.println("✓ 测试2通过: 自定义位置和字体 -> " + outputPath2);
+
+            // 测试3: 带压缩的水印
+            String outputPath3 = outputDir + "/output-compressed.jpg";
+            ImageWatermarkUtil.addWatermarkWithCompression(inputPath, outputPath3, "《绝世龙王》", 0.8);
+            System.out.println("✓ 测试3通过: 带压缩的水印 -> " + outputPath3);
+
+            // 测试4: PNG格式
+            String inputPng = outputDir + "/test-input.png";
+            createTestImage(inputPng, 1920, 1080);
+            String outputPng = outputDir + "/output.png";
+            ImageWatermarkUtil.addWatermark(inputPng, outputPng, "《绝世龙王》");
+            System.out.println("✓ 测试4通过: PNG格式 -> " + outputPng);
+
+            // 测试5: 多个剧名
+            String[] dramaNames = {
+                    "《霸道总裁爱上我》",
+                    "《重生之都市修仙》",
+                    "《全职高手》"
+            };
+            for (int i = 0; i < dramaNames.length; i++) {
+                String outputPath = outputDir + "/output-drama-" + i + ".jpg";
+                ImageWatermarkUtil.addWatermark(inputPath, outputPath, dramaNames[i]);
+                System.out.println("✓ 测试5." + (i + 1) + "通过: 剧名 " + dramaNames[i] + " -> " + outputPath);
+            }
+
+            System.out.println("\n========================================");
+            System.out.println("所有测试通过!✓");
+            System.out.println("测试结果保存在: " + new File(outputDir).getAbsolutePath());
+            System.out.println("========================================");
+
+        } catch (Exception e) {
+            System.err.println("✗ 测试失败: " + e.getMessage());
+            e.printStackTrace();
+            System.exit(1);
+        }
+    }
+
+    /**
+     * 创建测试图片
+     */
+    private static void createTestImage(String path, int width, int height) throws Exception {
+        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+        Graphics2D g2d = image.createGraphics();
+
+        // 填充渐变背景
+        GradientPaint gradient = new GradientPaint(0, 0, Color.BLUE, width, height, Color.CYAN);
+        g2d.setPaint(gradient);
+        g2d.fillRect(0, 0, width, height);
+
+        // 添加文字
+        g2d.setColor(Color.WHITE);
+        g2d.setFont(new Font("Arial", Font.BOLD, 48));
+        String text = "Test Image " + width + "x" + height;
+        FontMetrics fm = g2d.getFontMetrics();
+        int textWidth = fm.stringWidth(text);
+        g2d.drawString(text, (width - textWidth) / 2, height / 2);
+
+        g2d.dispose();
+
+        // 保存图片
+        String format = path.endsWith(".png") ? "png" : "jpg";
+        ImageIO.write(image, format, new File(path));
+    }
+}

+ 165 - 0
src/test/java/com/moka/gdtauto/util/ImageWatermarkUtilTest.java

@@ -0,0 +1,165 @@
+package com.moka.gdtauto.util;
+
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * 图片水印工具类测试
+ *
+ * @author moka
+ */
+@Slf4j
+class ImageWatermarkUtilTest {
+
+    private static final String TEST_OUTPUT_DIR = "target/test-output/watermark";
+    private static final String TEST_INPUT_IMAGE = TEST_OUTPUT_DIR + "/test-input.jpg";
+
+    @BeforeEach
+    void setUp() throws IOException {
+        // 创建测试输出目录
+        File outputDir = new File(TEST_OUTPUT_DIR);
+        if (!outputDir.exists()) {
+            outputDir.mkdirs();
+        }
+
+        // 生成测试用的图片(模拟原始图片)
+        createTestImage(TEST_INPUT_IMAGE, 1920, 1080);
+    }
+
+    /**
+     * 测试基本水印功能
+     */
+    @Test
+    void testAddWatermark() throws IOException {
+        String outputPath = TEST_OUTPUT_DIR + "/output-basic.jpg";
+        String watermarkText = "《绝世龙王》";
+
+        ImageWatermarkUtil.addWatermark(TEST_INPUT_IMAGE, outputPath, watermarkText);
+
+        // 验证输出文件是否存在
+        File outputFile = new File(outputPath);
+        assert outputFile.exists() : "输出文件不存在";
+        assert outputFile.length() > 0 : "输出文件为空";
+
+        log.info("基本水印测试通过,输出文件: {}", outputPath);
+    }
+
+    /**
+     * 测试自定义位置和字体大小
+     */
+    @Test
+    void testAddWatermarkWithCustomSettings() throws IOException {
+        String outputPath = TEST_OUTPUT_DIR + "/output-custom.jpg";
+        String watermarkText = "《绝世龙王》";
+
+        // 自定义:字体大小60,位置(100, 100)
+        ImageWatermarkUtil.addWatermark(TEST_INPUT_IMAGE, outputPath, watermarkText, 60, 100, 100);
+
+        File outputFile = new File(outputPath);
+        assert outputFile.exists() : "输出文件不存在";
+        assert outputFile.length() > 0 : "输出文件为空";
+
+        log.info("自定义设置水印测试通过,输出文件: {}", outputPath);
+    }
+
+    /**
+     * 测试带压缩的水印功能
+     */
+    @Test
+    void testAddWatermarkWithCompression() throws IOException {
+        String outputPath = TEST_OUTPUT_DIR + "/output-compressed.jpg";
+        String watermarkText = "《绝世龙王》";
+
+        // 缩放到0.8倍
+        ImageWatermarkUtil.addWatermarkWithCompression(TEST_INPUT_IMAGE, outputPath, watermarkText, 0.8);
+
+        File inputFile = new File(TEST_INPUT_IMAGE);
+        File outputFile = new File(outputPath);
+
+        assert outputFile.exists() : "输出文件不存在";
+        assert outputFile.length() > 0 : "输出文件为空";
+        assert outputFile.length() < inputFile.length() : "压缩后的文件应该更小";
+
+        log.info("压缩水印测试通过,输入大小: {} bytes, 输出大小: {} bytes", 
+                inputFile.length(), outputFile.length());
+    }
+
+    /**
+     * 测试不同格式的图片
+     */
+    @Test
+    void testAddWatermarkPNG() throws IOException {
+        String inputPath = TEST_OUTPUT_DIR + "/test-input.png";
+        String outputPath = TEST_OUTPUT_DIR + "/output.png";
+        String watermarkText = "《绝世龙王》";
+
+        // 创建PNG格式的测试图片
+        createTestImage(inputPath, 1920, 1080);
+
+        ImageWatermarkUtil.addWatermark(inputPath, outputPath, watermarkText);
+
+        File outputFile = new File(outputPath);
+        assert outputFile.exists() : "PNG输出文件不存在";
+        assert outputFile.length() > 0 : "PNG输出文件为空";
+
+        log.info("PNG格式水印测试通过,输出文件: {}", outputPath);
+    }
+
+    /**
+     * 测试多个不同的剧名
+     */
+    @Test
+    void testMultipleWatermarks() throws IOException {
+        String[] dramaNames = {
+                "《绝世龙王》",
+                "《霸道总裁爱上我》",
+                "《重生之都市修仙》",
+                "《全职高手》"
+        };
+
+        for (int i = 0; i < dramaNames.length; i++) {
+            String outputPath = TEST_OUTPUT_DIR + "/output-drama-" + i + ".jpg";
+            ImageWatermarkUtil.addWatermark(TEST_INPUT_IMAGE, outputPath, dramaNames[i]);
+
+            File outputFile = new File(outputPath);
+            assert outputFile.exists() : "输出文件不存在: " + dramaNames[i];
+            log.info("剧名 {} 水印添加成功", dramaNames[i]);
+        }
+
+        log.info("多剧名水印测试全部通过");
+    }
+
+    /**
+     * 创建测试用的图片
+     */
+    private void createTestImage(String path, int width, int height) throws IOException {
+        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+        Graphics2D g2d = image.createGraphics();
+
+        // 填充渐变背景
+        GradientPaint gradient = new GradientPaint(0, 0, Color.BLUE, width, height, Color.CYAN);
+        g2d.setPaint(gradient);
+        g2d.fillRect(0, 0, width, height);
+
+        // 添加一些文字说明
+        g2d.setColor(Color.WHITE);
+        g2d.setFont(new Font("Arial", Font.BOLD, 48));
+        g2d.drawString("Test Image " + width + "x" + height, width / 2 - 200, height / 2);
+
+        g2d.dispose();
+
+        // 保存图片
+        File outputFile = new File(path);
+        String format = path.endsWith(".png") ? "png" : "jpg";
+        ImageIO.write(image, format, outputFile);
+
+        log.info("创建测试图片: {}, 尺寸: {}x{}", path, width, height);
+    }
+}

+ 128 - 0
src/test/java/com/moka/gdtauto/util/VideoWatermarkSimpleTest.java

@@ -0,0 +1,128 @@
+package com.moka.gdtauto.util;
+
+import org.bytedeco.javacv.FFmpegFrameRecorder;
+import org.bytedeco.javacv.Java2DFrameConverter;
+
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+
+/**
+ * 视频水印简单测试程序
+ * 可以直接运行main方法进行测试
+ * 注意:视频处理较慢,请耐心等待
+ */
+public class VideoWatermarkSimpleTest {
+
+    public static void main(String[] args) {
+        try {
+            String outputDir = "target/test-output/video-watermark";
+            File dir = new File(outputDir);
+            if (!dir.exists()) {
+                dir.mkdirs();
+            }
+
+            // 创建测试视频(3秒,10fps)
+            String inputPath = outputDir + "/test-input.mp4";
+            if (!new File(inputPath).exists()) {
+                System.out.println("正在创建测试视频(3秒,10fps)...");
+                createTestVideo(inputPath, 1280, 720, 10, 3);
+                System.out.println("✓ 创建测试视频成功: " + inputPath);
+            } else {
+                System.out.println("✓ 使用已存在的测试视频: " + inputPath);
+            }
+
+            // 测试1: 基本视频水印功能
+            System.out.println("\n开始测试1: 基本视频水印功能...");
+            String outputPath1 = outputDir + "/output-basic.mp4";
+            VideoWatermarkUtil.addWatermark(inputPath, outputPath1, "《绝世龙王》");
+            System.out.println("✓ 测试1通过: 基本视频水印功能 -> " + outputPath1);
+
+            // 测试2: 提取第一帧
+            System.out.println("\n开始测试2: 提取视频第一帧...");
+            String firstFramePath = outputDir + "/first-frame.jpg";
+            VideoWatermarkUtil.extractFirstFrame(inputPath, firstFramePath);
+            System.out.println("✓ 测试2通过: 提取第一帧 -> " + firstFramePath);
+
+            System.out.println("\n========================================");
+            System.out.println("所有测试通过!✓");
+            System.out.println("测试结果保存在: " + new File(outputDir).getAbsolutePath());
+            System.out.println("========================================");
+
+        } catch (Exception e) {
+            System.err.println("✗ 测试失败: " + e.getMessage());
+            e.printStackTrace();
+            System.exit(1);
+        }
+    }
+
+    /**
+     * 创建测试视频
+     */
+    private static void createTestVideo(String path, int width, int height, double frameRate, int duration) throws Exception {
+        FFmpegFrameRecorder recorder = null;
+        Java2DFrameConverter converter = new Java2DFrameConverter();
+
+        try {
+            recorder = new FFmpegFrameRecorder(path, width, height, 0);
+            recorder.setVideoCodec(org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_H264);
+            recorder.setFormat("mp4");
+            recorder.setFrameRate(frameRate);
+            recorder.setVideoBitrate(2000000);
+            recorder.start();
+
+            int totalFrames = (int) (frameRate * duration);
+
+            for (int i = 0; i < totalFrames; i++) {
+                BufferedImage image = createTestFrame(width, height, i, totalFrames);
+                recorder.record(converter.convert(image));
+
+                if (i % 5 == 0 || i == totalFrames - 1) {
+                    System.out.printf("  进度: %d/%d 帧 (%.1f%%)\n", 
+                            i + 1, totalFrames, (i + 1) * 100.0 / totalFrames);
+                }
+            }
+
+        } finally {
+            if (recorder != null) {
+                try {
+                    recorder.stop();
+                    recorder.release();
+                } catch (Exception e) {
+                    // ignore
+                }
+            }
+            converter.close();
+        }
+    }
+
+    /**
+     * 创建测试帧
+     */
+    private static BufferedImage createTestFrame(int width, int height, int frameIndex, int totalFrames) {
+        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+        Graphics2D g2d = image.createGraphics();
+
+        // 颜色渐变
+        float progress = (float) frameIndex / totalFrames;
+        Color startColor = Color.BLUE;
+        Color endColor = Color.CYAN;
+        int r = (int) (startColor.getRed() + (endColor.getRed() - startColor.getRed()) * progress);
+        int g = (int) (startColor.getGreen() + (endColor.getGreen() - startColor.getGreen()) * progress);
+        int b = (int) (startColor.getBlue() + (endColor.getBlue() - startColor.getBlue()) * progress);
+
+        g2d.setColor(new Color(r, g, b));
+        g2d.fillRect(0, 0, width, height);
+
+        // 添加文字
+        g2d.setColor(Color.WHITE);
+        g2d.setFont(new Font("Arial", Font.BOLD, 36));
+        String text = String.format("Frame %d/%d", frameIndex + 1, totalFrames);
+        FontMetrics fm = g2d.getFontMetrics();
+        int textWidth = fm.stringWidth(text);
+        g2d.drawString(text, (width - textWidth) / 2, height / 2);
+
+        g2d.dispose();
+        return image;
+    }
+}

+ 196 - 0
src/test/java/com/moka/gdtauto/util/VideoWatermarkUtilTest.java

@@ -0,0 +1,196 @@
+package com.moka.gdtauto.util;
+
+import lombok.extern.slf4j.Slf4j;
+import org.bytedeco.javacv.FFmpegFrameRecorder;
+import org.bytedeco.javacv.Java2DFrameConverter;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * 视频水印工具类测试
+ * 注意:视频处理需要较长时间,测试会生成测试视频文件
+ *
+ * @author moka
+ */
+@Slf4j
+class VideoWatermarkUtilTest {
+
+    private static final String TEST_OUTPUT_DIR = "target/test-output/video-watermark";
+    private static final String TEST_INPUT_VIDEO = TEST_OUTPUT_DIR + "/test-input.mp4";
+
+    @BeforeEach
+    void setUp() throws Exception {
+        // 创建测试输出目录
+        File outputDir = new File(TEST_OUTPUT_DIR);
+        if (!outputDir.exists()) {
+            outputDir.mkdirs();
+        }
+
+        // 生成测试用的视频(5秒,30fps)
+        if (!new File(TEST_INPUT_VIDEO).exists()) {
+            createTestVideo(TEST_INPUT_VIDEO, 1280, 720, 30, 5);
+        }
+    }
+
+    /**
+     * 测试基本视频水印功能
+     */
+    @Test
+    void testAddWatermark() throws Exception {
+        String outputPath = TEST_OUTPUT_DIR + "/output-basic.mp4";
+        String watermarkText = "《绝世龙王》";
+
+        VideoWatermarkUtil.addWatermark(TEST_INPUT_VIDEO, outputPath, watermarkText);
+
+        // 验证输出文件是否存在
+        File outputFile = new File(outputPath);
+        assert outputFile.exists() : "输出视频文件不存在";
+        assert outputFile.length() > 0 : "输出视频文件为空";
+
+        log.info("基本视频水印测试通过,输出文件: {}", outputPath);
+    }
+
+    /**
+     * 测试自定义位置和字体大小
+     */
+    @Test
+    void testAddWatermarkWithCustomSettings() throws Exception {
+        String outputPath = TEST_OUTPUT_DIR + "/output-custom.mp4";
+        String watermarkText = "《绝世龙王》";
+
+        // 自定义:字体大小60,位置(100, 100)
+        VideoWatermarkUtil.addWatermark(TEST_INPUT_VIDEO, outputPath, watermarkText, 60, 5, 100, 100);
+
+        File outputFile = new File(outputPath);
+        assert outputFile.exists() : "输出视频文件不存在";
+        assert outputFile.length() > 0 : "输出视频文件为空";
+
+        log.info("自定义设置视频水印测试通过,输出文件: {}", outputPath);
+    }
+
+    /**
+     * 测试提取视频第一帧
+     */
+    @Test
+    void testExtractFirstFrame() throws Exception {
+        String outputPath = TEST_OUTPUT_DIR + "/first-frame.jpg";
+
+        VideoWatermarkUtil.extractFirstFrame(TEST_INPUT_VIDEO, outputPath);
+
+        File outputFile = new File(outputPath);
+        assert outputFile.exists() : "提取的第一帧图片不存在";
+        assert outputFile.length() > 0 : "提取的第一帧图片为空";
+
+        log.info("提取第一帧测试通过,输出文件: {}", outputPath);
+    }
+
+    /**
+     * 测试多个不同的剧名
+     */
+    @Test
+    void testMultipleWatermarks() throws Exception {
+        String[] dramaNames = {
+                "《绝世龙王》",
+                "《霸道总裁爱上我》"
+        };
+
+        for (int i = 0; i < dramaNames.length; i++) {
+            String outputPath = TEST_OUTPUT_DIR + "/output-drama-" + i + ".mp4";
+            VideoWatermarkUtil.addWatermark(TEST_INPUT_VIDEO, outputPath, dramaNames[i]);
+
+            File outputFile = new File(outputPath);
+            assert outputFile.exists() : "输出视频文件不存在: " + dramaNames[i];
+            log.info("剧名 {} 视频水印添加成功", dramaNames[i]);
+        }
+
+        log.info("多剧名视频水印测试全部通过");
+    }
+
+    /**
+     * 创建测试用的视频
+     *
+     * @param path      输出路径
+     * @param width     宽度
+     * @param height    高度
+     * @param frameRate 帧率
+     * @param duration  时长(秒)
+     */
+    private void createTestVideo(String path, int width, int height, double frameRate, int duration) throws Exception {
+        log.info("开始创建测试视频: {}, 尺寸: {}x{}, 帧率: {}, 时长: {}秒", path, width, height, frameRate, duration);
+
+        FFmpegFrameRecorder recorder = null;
+        Java2DFrameConverter converter = new Java2DFrameConverter();
+
+        try {
+            // 创建录制器
+            recorder = new FFmpegFrameRecorder(path, width, height, 0); // 0 表示没有音频
+            recorder.setVideoCodec(org.bytedeco.ffmpeg.global.avcodec.AV_CODEC_ID_H264);
+            recorder.setFormat("mp4");
+            recorder.setFrameRate(frameRate);
+            recorder.setVideoBitrate(2000000);
+            recorder.start();
+
+            int totalFrames = (int) (frameRate * duration);
+
+            // 生成每一帧
+            for (int i = 0; i < totalFrames; i++) {
+                BufferedImage image = createTestFrame(width, height, i, totalFrames);
+                recorder.record(converter.convert(image));
+
+                if (i % 30 == 0) {
+                    log.info("创建测试视频进度: {}/{}", i, totalFrames);
+                }
+            }
+
+            log.info("测试视频创建成功: {}", path);
+
+        } finally {
+            if (recorder != null) {
+                try {
+                    recorder.stop();
+                    recorder.release();
+                } catch (Exception e) {
+                    log.error("关闭recorder失败", e);
+                }
+            }
+            converter.close();
+        }
+    }
+
+    /**
+     * 创建测试帧
+     */
+    private BufferedImage createTestFrame(int width, int height, int frameIndex, int totalFrames) {
+        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+        Graphics2D g2d = image.createGraphics();
+
+        // 计算颜色(从蓝色渐变到青色)
+        float progress = (float) frameIndex / totalFrames;
+        Color startColor = Color.BLUE;
+        Color endColor = Color.CYAN;
+        int r = (int) (startColor.getRed() + (endColor.getRed() - startColor.getRed()) * progress);
+        int g = (int) (startColor.getGreen() + (endColor.getGreen() - startColor.getGreen()) * progress);
+        int b = (int) (startColor.getBlue() + (endColor.getBlue() - startColor.getBlue()) * progress);
+
+        // 填充背景
+        g2d.setColor(new Color(r, g, b));
+        g2d.fillRect(0, 0, width, height);
+
+        // 添加文字说明
+        g2d.setColor(Color.WHITE);
+        g2d.setFont(new Font("Arial", Font.BOLD, 36));
+        String text = String.format("Test Video Frame %d/%d", frameIndex + 1, totalFrames);
+        FontMetrics fm = g2d.getFontMetrics();
+        int textWidth = fm.stringWidth(text);
+        g2d.drawString(text, (width - textWidth) / 2, height / 2);
+
+        g2d.dispose();
+        return image;
+    }
+}