Forráskód Böngészése

feat: 创意图片每次上传

pudongliang 4 hónapja
szülő
commit
79207bbafc
32 módosított fájl, 2551 hozzáadás és 127 törlés
  1. BIN
      brand.jpg
  2. 24 0
      pom.xml
  3. 60 0
      portal/src/api/ossFile.js
  4. 23 16
      portal/src/router/index.js
  5. 94 5
      portal/src/views/ad-plan-conf/detail.vue
  6. 352 11
      portal/src/views/ad-plan-conf/index.vue
  7. 61 59
      portal/src/views/dashboard/index.vue
  8. 1 1
      portal/src/views/login/index.vue
  9. 516 0
      portal/src/views/oss-file/index.vue
  10. 23 0
      portal/src/views/user/index.vue
  11. 50 0
      src/main/java/com/moka/gdtauto/config/AliyunOssConfig.java
  12. 33 12
      src/main/java/com/moka/gdtauto/controller/AdPlanConfController.java
  13. 150 0
      src/main/java/com/moka/gdtauto/controller/OssFileController.java
  14. 15 0
      src/main/java/com/moka/gdtauto/controller/vo/CreateAdPlanConfRequest.java
  15. 15 0
      src/main/java/com/moka/gdtauto/controller/vo/UpdateAdPlanConfRequest.java
  16. 25 0
      src/main/java/com/moka/gdtauto/entity/AdPlanConf.java
  17. 6 0
      src/main/java/com/moka/gdtauto/entity/AdPlanCreative.java
  18. 90 0
      src/main/java/com/moka/gdtauto/entity/OssFile.java
  19. 15 0
      src/main/java/com/moka/gdtauto/mapper/OssFileMapper.java
  20. 57 0
      src/main/java/com/moka/gdtauto/service/OssFileService.java
  21. 263 22
      src/main/java/com/moka/gdtauto/service/impl/AdPlanConfServiceImpl.java
  22. 173 0
      src/main/java/com/moka/gdtauto/service/impl/OssFileServiceImpl.java
  23. 233 0
      src/main/java/com/moka/gdtauto/util/OssUtil.java
  24. 9 0
      src/main/resources/application-local.yml
  25. 9 0
      src/main/resources/application-pro.yml
  26. 9 0
      src/main/resources/application-test.yml
  27. 7 1
      src/main/resources/application.yml
  28. 22 0
      src/main/resources/sql/oss_file.sql
  29. 4 0
      src/main/resources/sql/oss_file_add_business_type.sql
  30. 3 0
      src/test/java/com/moka/gdtauto/service/AdPlanConfAsyncTest.java
  31. 179 0
      src/test/java/com/moka/gdtauto/service/TencentBrandAddTest.java
  32. 30 0
      src/test/java/com/moka/gdtauto/util/OssUtil.java

BIN
brand.jpg


+ 24 - 0
pom.xml

@@ -186,6 +186,30 @@
             <artifactId>spring-boot-starter-test</artifactId>
             <scope>test</scope>
         </dependency>
+
+        <!-- Source: https://mvnrepository.com/artifact/com.aliyun.oss/aliyun-sdk-oss -->
+        <dependency>
+            <groupId>com.aliyun.oss</groupId>
+            <artifactId>aliyun-sdk-oss</artifactId>
+            <version>3.18.5</version>
+            <scope>compile</scope>
+        </dependency>
+        <dependency>
+            <groupId>javax.xml.bind</groupId>
+            <artifactId>jaxb-api</artifactId>
+            <version>2.3.1</version>
+        </dependency>
+        <dependency>
+            <groupId>javax.activation</groupId>
+            <artifactId>activation</artifactId>
+            <version>1.1.1</version>
+        </dependency>
+        <!-- no more than 2.3.3-->
+        <dependency>
+            <groupId>org.glassfish.jaxb</groupId>
+            <artifactId>jaxb-runtime</artifactId>
+            <version>2.3.3</version>
+        </dependency>
     </dependencies>
 
     <build>

+ 60 - 0
portal/src/api/ossFile.js

@@ -0,0 +1,60 @@
+import request from '../utils/request'
+
+/**
+ * 上传文件
+ */
+export function uploadFile(formData) {
+  return request({
+    timeout: 300000,
+    url: '/api/oss-file/upload',
+    method: 'post',
+    data: formData,
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })
+}
+
+/**
+ * 获取文件列表
+ */
+export function getFileList(params) {
+  return request({
+    url: '/api/oss-file/list',
+    method: 'get',
+    params
+  })
+}
+
+/**
+ * 获取文件详情
+ */
+export function getFileDetail(id) {
+  return request({
+    url: `/api/oss-file/${id}`,
+    method: 'get'
+  })
+}
+
+/**
+ * 删除文件
+ */
+export function deleteFile(id) {
+  return request({
+    url: `/api/oss-file/${id}`,
+    method: 'delete'
+  })
+}
+
+/**
+ * 批量删除文件
+ */
+export function batchDeleteFiles(fileIds) {
+  return request({
+    url: '/api/oss-file/batch',
+    method: 'delete',
+    params: {
+      fileIds
+    }
+  })
+}

+ 23 - 16
portal/src/router/index.js

@@ -38,18 +38,31 @@ const routes = [
         meta: { title: '实名认证管理', icon: 'UserFilled' }
       },
       {
-        path: 'account',
-        name: 'Account',
-        component: () => import('@/views/account/index.vue'),
-        meta: { title: '广告账户管理', icon: 'User' }
-      },
-      {
         path: 'account-group',
         name: 'AccountGroup',
         component: () => import('@/views/account-group/index.vue'),
         meta: { title: '账号组管理', icon: 'Grid' }
       },
       {
+        path: 'ad-plan-conf',
+        name: 'AdPlanConf',
+        component: () => import('@/views/ad-plan-conf/index.vue'),
+        meta: { title: '广告计划配置', icon: 'Setting' }
+      },
+      {
+        path: 'ad-plan-conf/detail',
+        name: 'AdPlanConfDetail',
+        component: () => import('@/views/ad-plan-conf/detail.vue'),
+        meta: { title: '广告计划配置详情', icon: 'Document', hideInMenu: true }
+      },
+      {
+        path: 'account',
+        name: 'Account',
+        component: () => import('@/views/account/index.vue'),
+        meta: { title: '广告账户管理', icon: 'User' }
+      },
+      
+      {
         path: 'conversion',
         name: 'Conversion',
         component: () => import('@/views/conversion/index.vue'),
@@ -80,16 +93,10 @@ const routes = [
         meta: { title: '视频上传', icon: 'VideoCamera' }
       },
       {
-        path: 'ad-plan-conf',
-        name: 'AdPlanConf',
-        component: () => import('@/views/ad-plan-conf/index.vue'),
-        meta: { title: '广告计划配置', icon: 'Setting' }
-      },
-      {
-        path: 'ad-plan-conf/detail',
-        name: 'AdPlanConfDetail',
-        component: () => import('@/views/ad-plan-conf/detail.vue'),
-        meta: { title: '广告计划配置详情', icon: 'Document', hideInMenu: true }
+        path: 'oss-file',
+        name: 'OssFile',
+        component: () => import('@/views/oss-file/index.vue'),
+        meta: { title: 'OSS文件管理', icon: 'FolderOpened' }
       }
     ]
   }

+ 94 - 5
portal/src/views/ad-plan-conf/detail.vue

@@ -10,11 +10,14 @@
 
     <!-- 配置信息卡片 -->
     <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="border-b border-gray-200 pb-4 mb-6 flex items-center justify-between">
         <h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
           <el-icon><Document /></el-icon>
           配置信息
         </h2>
+        <el-button type="primary" @click="handleRefresh(confDetail.id)">
+            <el-icon><Refresh /></el-icon>
+            刷新计划状态</el-button>
       </div>
       
       <div v-if="confDetail">
@@ -82,7 +85,51 @@
                 <div class="text-sm text-gray-500">落地页IDs</div>
                 <div class="text-base font-medium">{{ confDetail.pageIds || '-' }}</div>
               </div>
-              <div class="space-y-2" v-if="confDetail.failReason">
+              <div class="space-y-2">
+                <div class="text-sm text-gray-500">创意图片/视频</div>
+                <div class="text-base font-medium">
+                  <div v-if="confDetail.creativeFileUrls" class="flex flex-wrap gap-2">
+                    <el-image
+                      v-for="(url, index) in confDetail.creativeFileUrls.split(',')"
+                      :key="index"
+                      :src="url"
+                      :preview-src-list="confDetail.creativeFileUrls.split(',')"
+                      :initial-index="index"
+                      fit="cover"
+                      style="width: 80px; height: 80px"
+                      class="cursor-pointer rounded border"
+                    />
+                  </div>
+                  <span v-else>-</span>
+                </div>
+              </div>
+              <div class="space-y-2">
+                <div class="text-sm text-gray-500">品牌形象图片</div>
+                <div class="text-base font-medium">
+                  <el-image
+                    v-if="confDetail.brandImageUrl"
+                    :src="confDetail.brandImageUrl"
+                    :preview-src-list="[confDetail.brandImageUrl]"
+                    fit="cover"
+                    style="width: 120px; height: 120px"
+                    class="cursor-pointer rounded border"
+                  />
+                  <span v-else>-</span>
+                </div>
+              </div>
+              <div class="space-y-2">
+                <div class="text-sm text-gray-500">创意文案</div>
+                <div class="text-base font-medium whitespace-pre-wrap">{{ confDetail.creativeText || '-' }}</div>
+              </div>
+              <div class="space-y-2">
+                <div class="text-sm text-gray-500">品牌名称</div>
+                <div class="text-base font-medium">{{ confDetail.brandName || '-' }}</div>
+              </div>
+              <div class="space-y-2">
+                <div class="text-sm text-gray-500">按钮文案</div>
+                <div class="text-base font-medium">{{ confDetail.buttonText || '-' }}</div>
+              </div>
+              <div class="space-y-2">
                 <div class="text-sm text-gray-500">失败原因</div>
                 <div class="text-base text-red-500">{{ confDetail.failReason }}</div>
               </div>
@@ -111,7 +158,14 @@
           <el-icon><List /></el-icon>
           广告计划列表
         </h2>
-        <el-button type="primary" @click="handleReGeneratePlan(confDetail.id)">重新生成计划</el-button>
+        <div>
+           <el-button type="primary" @click="handleRefresh(confDetail.id)">
+            <el-icon><Refresh /></el-icon>
+            刷新计划状态</el-button>
+        <el-button type="primary" @click="handleReGeneratePlan(confDetail.id)">
+          <el-icon><Refresh /></el-icon>
+          重新生成广告/创意</el-button>
+        </div>
         </div>
         
       </div>
@@ -170,7 +224,37 @@
                 <el-table-column prop="id" label="创意ID" width="100" />
                 <el-table-column prop="creativeName" label="创意名称" min-width="200" show-overflow-tooltip />
                 <el-table-column prop="creativeId" label="腾讯创意ID" width="150" />
+                <el-table-column label="创意图片/视频" width="120">
+                  <template #default="{ row: creative }">
+                    <el-image
+                      v-if="creative.creativeFileUrl"
+                      :src="creative.creativeFileUrl"
+                      :preview-src-list="[creative.creativeFileUrl]"
+                      fit="cover"
+                      style="width: 60px; height: 60px"
+                      class="cursor-pointer rounded border"
+                    />
+                    <span v-else>-</span>
+                  </template>
+                </el-table-column>
                 <el-table-column prop="imageId" label="图片ID" width="120" />
+                <el-table-column prop="creativeText" label="创意文案" min-width="150" show-overflow-tooltip />
+                <el-table-column prop="brandName" label="品牌名称" width="120" />
+                <el-table-column label="品牌形象图片" width="120">
+                  <template #default="{ row: creative }">
+                    <el-image
+                      v-if="creative.brandImageUrl"
+                      :src="creative.brandImageUrl"
+                      :preview-src-list="[creative.brandImageUrl]"
+                      fit="cover"
+                      style="width: 60px; height: 60px"
+                      class="cursor-pointer rounded border"
+                    />
+                    <span v-else>-</span>
+                  </template>
+                </el-table-column>
+                <el-table-column prop="brandImageId" label="品牌形象图片ID" width="120" />
+                <el-table-column prop="buttonText" label="按钮文案" width="120" />
                 <el-table-column prop="pageIds" label="落地页IDs" min-width="150" show-overflow-tooltip />
                 <el-table-column label="状态" width="100">
                   <template #default="{ row: creative }">
@@ -209,8 +293,8 @@
             ¥{{ (row.bidAmount / 100).toFixed(2) }}
           </template>
         </el-table-column>
-        <el-table-column prop="target" label="定向" width="100" show-overflow-tooltip />
-        <el-table-column prop="site" label="版位" width="100" show-overflow-tooltip />
+        <el-table-column prop="target" label="定向模版ID" width="100" show-overflow-tooltip />
+        <el-table-column prop="site" label="版位模版ID" width="100" show-overflow-tooltip />
         <el-table-column label="状态" width="100">
           <template #default="{ row }">
             <el-tag :type="getPlanStatusColor(row.status)" size="small">
@@ -443,6 +527,11 @@ const formatDate = (dateStr) => {
   })
 }
 
+const handleRefresh = async () => {
+  await fetchConfDetail()
+  await fetchAdPlans()
+}
+
 // 初始化
 onMounted(() => {
   if (!queryParams.value.confId) {

+ 352 - 11
portal/src/views/ad-plan-conf/index.vue

@@ -109,6 +109,7 @@
             placeholder="请输入计划名称"
             clearable
           />
+          <span>作为生成广告名称的前缀用</span>
         </el-form-item>
         
         <el-form-item label="广告类型" prop="adType">
@@ -138,8 +139,10 @@
             v-model="bidAmountYuan"
             :min="0"
             :precision="2"
+            :step="0.10"
             placeholder="请输入出价金额"
             style="width: 100%"
+            :controls="true"
           />
           <span class="ml-2 text-sm text-gray-500">= {{ formData.bidAmount }} 分</span>
         </el-form-item>
@@ -209,6 +212,94 @@
             placeholder="多个落地页ID用逗号分隔,例如:1,2,3"
           />
         </el-form-item>
+
+        <el-form-item label="创意图片/视频">
+          <div class="flex flex-col gap-2">
+            <el-button size="small" @click="openFileSelector('creative')" style="width: fit-content">选择文件</el-button>
+            <div v-if="formData.creativeFileUrls" class="flex flex-wrap gap-2">
+              <div
+                v-for="(url, index) in formData.creativeFileUrls.split(',')"
+                :key="index"
+                class="relative inline-block"
+              >
+                <el-image
+                  :src="url"
+                  :preview-src-list="formData.creativeFileUrls.split(',')"
+                  :initial-index="index"
+                  fit="cover"
+                  style="width: 80px; height: 80px; display: block"
+                  class="cursor-pointer rounded border"
+                />
+                <div
+                  @click="removeFile('creative', index)"
+                  class="absolute -top-1 -right-1 cursor-pointer bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center hover:bg-red-600 transition-colors"
+                  style="z-index: 10"
+                >
+                  <el-icon :size="14">
+                    <Close />
+                  </el-icon>
+                </div>
+              </div>
+            </div>
+          </div>
+        </el-form-item>
+
+        <el-form-item label="品牌形象图片">
+          <div class="flex flex-col gap-2">
+            <el-button size="small" @click="openFileSelector('brand')" style="width: fit-content">选择图片</el-button>
+            <div v-if="formData.brandImageUrl">
+              <div class="relative inline-block">
+                <el-image
+                  :src="formData.brandImageUrl"
+                  :preview-src-list="[formData.brandImageUrl]"
+                  fit="cover"
+                  style="width: 80px; height: 80px; display: block"
+                  class="cursor-pointer rounded border"
+                />
+                <div
+                  @click="removeFile('brand', 0)"
+                  class="absolute -top-1 -right-1 cursor-pointer bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center hover:bg-red-600 transition-colors"
+                  style="z-index: 10"
+                >
+                  <el-icon :size="14">
+                    <Close />
+                  </el-icon>
+                </div>
+              </div>
+            </div>
+          </div>
+        </el-form-item>
+
+        <el-form-item label="创意文案">
+          <el-input
+            v-model="formData.creativeText"
+            type="textarea"
+            :rows="3"
+            placeholder="请输入创意文案"
+            maxlength="30"
+            show-word-limit
+          />
+        </el-form-item>
+
+        <el-form-item label="品牌名称">
+          <el-input
+            v-model="formData.brandName"
+            placeholder="请输入品牌名称"
+            maxlength="20"
+            show-word-limit
+            clearable
+          />
+        </el-form-item>
+
+        <el-form-item label="按钮文案">
+          <el-input
+            v-model="formData.buttonText"
+            placeholder="请输入按钮文案"
+            maxlength="20"
+            show-word-limit
+            clearable
+          />
+        </el-form-item>
       </el-form>
       <el-button type="primary" @click="handleReCreate">预览</el-button>
       <el-table :data="plans" 
@@ -219,16 +310,45 @@
             <div m="4">
             <h3>创意</h3>
             <el-table :data="props.row.adCreatives" border>
-                <el-table-column label="图片" prop="imageId" />
-                <el-table-column label="落地页ID" prop="pageIds" />
+                <el-table-column label="创意图片/视频" width="120">
+                  <template #default="{ row }">
+                    <el-image
+                      v-if="row.creativeFileUrl"
+                      :src="row.creativeFileUrl"
+                      :preview-src-list="[row.creativeFileUrl]"
+                      fit="cover"
+                      style="width: 60px; height: 60px"
+                      class="cursor-pointer rounded border"
+                    />
+                    <span v-else>-</span>
+                  </template>
+                </el-table-column>
+                <el-table-column prop="creativeText" label="创意文案" min-width="150" show-overflow-tooltip />
+                <el-table-column prop="brandName" label="品牌名称" width="120" />
+                <el-table-column label="品牌形象图片" width="120">
+                  <template #default="{ row }">
+                    <el-image
+                      v-if="row.brandImageUrl"
+                      :src="row.brandImageUrl"
+                      :preview-src-list="[row.brandImageUrl]"
+                      fit="cover"
+                      style="width: 60px; height: 60px"
+                      class="cursor-pointer rounded border"
+                    />
+                    <span v-else>-</span>
+                  </template>
+                </el-table-column>
+                <el-table-column prop="buttonText" label="按钮文案" width="120" />
+                <el-table-column label="图片ID" prop="imageId" width="100" />
+                <el-table-column label="落地页IDs" prop="pageIds" min-width="150" show-overflow-tooltip />
             </el-table>
             </div>
         </template>
         </el-table-column>
         <el-table-column prop="adgroupName" label="计划名称" width="200" show-overflow-tooltip />
         <el-table-column prop="accountId" label="账号ID" width="200" show-overflow-tooltip />
-        <el-table-column prop="target" label="定向IDs" width="150" show-overflow-tooltip />
-        <el-table-column prop="site" label="版位IDs" width="150" show-overflow-tooltip />
+        <el-table-column prop="target" label="定向模版ID" width="150" show-overflow-tooltip />
+        <el-table-column prop="site" label="版位模版ID" width="150" show-overflow-tooltip />
         <el-table-column prop="adType" label="广告类型" width="150">
           <template #default="{ row }">
             <el-tag :type="getAdTypeColor(row.adType)">
@@ -250,7 +370,7 @@
         <el-tooltip
         class="box-item"
         effect="dark"
-        content="请先进行预览操作"
+        :content="plans.length === 0 ? '请先进行预览操作' : ''"
         placement="top-start"
       >
         <el-button :disabled="plans.length === 0" @click="handleGeneratePlan" >生成计划</el-button>
@@ -261,6 +381,95 @@
         </el-button>
       </template>
     </el-dialog>
+
+    <!-- 文件选择对话框 -->
+    <el-dialog
+      v-model="fileSelectorVisible"
+      :title="fileSelectorTitle"
+      width="70%"
+    >
+      <div class="mb-4 flex gap-4">
+        <el-input
+          v-model="fileSearchParams.fileName"
+          placeholder="搜索文件名"
+          clearable
+          style="width: 200px"
+        />
+        <el-button type="primary" @click="loadFileList">搜索</el-button>
+      </div>
+
+      <el-table
+        :data="fileList"
+        v-loading="fileLoading"
+        @selection-change="handleFileSelectionChange"
+        stripe
+        border
+      >
+        <el-table-column type="selection" width="55" />
+        <el-table-column prop="fileUrl" label="展示" min-width="200" show-overflow-tooltip >
+          <template #default="{ row }">
+            <el-image
+              :src="row.fileUrl"
+              style="width: 100px; height: 100px"
+              fit="cover"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column prop="fileType" label="类型" width="100" />
+        <el-table-column label="业务分类" width="120">
+          <template #default="{ row }">
+            <el-tag v-if="row.businessType === 1" type="success">创意</el-tag>
+            <el-tag v-else-if="row.businessType === 2" type="primary">品牌形象</el-tag>
+            <el-tag v-else type="info">未分类</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="文件大小" width="100">
+          <template #default="{ row }">
+            {{ formatFileSize(row.fileSize) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="创建时间" width="180">
+          <template #default="{ row }">
+            {{ formatDate(row.createdTime) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="预览" width="100">
+          <template #default="{ row }">
+            <el-button
+              v-if="row.fileType === 'image'"
+              size="small"
+              link
+              @click="previewFile(row.fileUrl)"
+            >
+              查看
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="mt-4 flex justify-end">
+        <el-pagination
+          v-if="fileTotal > 0"
+          v-model:current-page="fileSearchParams.page"
+          v-model:page-size="fileSearchParams.size"
+          :page-sizes="[10, 20, 50]"
+          :total="fileTotal"
+          layout="total, sizes, prev, pager, next"
+          @current-change="loadFileList"
+          @size-change="loadFileList"
+        />
+      </div>
+
+      <template #footer>
+        <el-button @click="fileSelectorVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmFileSelection">确定</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 图片预览对话框 -->
+    <el-dialog v-model="previewVisible" title="图片预览" width="60%">
+      <img :src="previewUrl" style="width: 100%" />
+    </el-dialog>
   </div>
 </template>
 
@@ -277,15 +486,16 @@ import {
 } from '@/api/adPlanConf'
 import { getConversions } from '@/api/conversion'
 import { getAccountGroupListAll } from '@/api/accountGroup'
+import { getFileList } from '@/api/ossFile'
 
 import {
   Setting,
   Plus,
   Edit,
-  Delete
+  Delete,
+  Close
 } from '@element-plus/icons-vue'
-import { useRouter } from 'vue-router'
-const router = useRouter()
+import router from '@/router'
 
 // 数据
 const loading = ref(false)
@@ -304,17 +514,41 @@ const conversions = ref([])
 const accountGroupList = ref([])
 const plans = ref([])
 
+// 文件选择
+const fileSelectorVisible = ref(false)
+const fileSelectorTitle = ref('')
+const currentFileType = ref('') // 'creative' or 'brand'
+const fileList = ref([])
+const fileLoading = ref(false)
+const fileTotal = ref(0)
+const selectedFiles = ref([])
+const fileSearchParams = ref({
+  page: 1,
+  size: 10,
+  fileName: '',
+  businessType: null
+})
+
+// 图片预览
+const previewVisible = ref(false)
+const previewUrl = ref('')
+
 // 表单数据
 const formData = ref({
   name: '',
   accountGroupId: null,
   adType: 1,
-  bidAmount: 0,
+  bidAmount: 150,
   conversionId: null,
   wxCorpid: '',
   dramaId: null,
   imageIds: '',
   pageIds: '',
+  creativeFileUrls: '',
+  brandImageUrl: '',
+  creativeText: '',
+  brandName: '',
+  buttonText: '',
   jobNumber: ''
 })
 
@@ -322,6 +556,7 @@ const formData = ref({
 const bidAmountYuan = computed({
   get: () => formData.value.bidAmount / 100,
   set: (val) => {
+    // 保留两位小数,然后转换为分
     formData.value.bidAmount = Math.round(val * 100)
   }
 })
@@ -428,7 +663,7 @@ const handleCreate = () => {
     name: '',
     accountGroupId: null,
     adType: 1,
-    bidAmount: 1500,
+    bidAmount: 150,
     conversionId: null,
     targets: null,
     sites: null,
@@ -436,6 +671,11 @@ const handleCreate = () => {
     dramaId: null,
     imageIds: '',
     pageIds: '',
+    creativeFileUrls: '',
+    brandImageUrl: '',
+    creativeText: '',
+    brandName: '',
+    buttonText: '',
     jobNumber: ''
   }
   plans.value = []
@@ -457,6 +697,11 @@ const handleEdit = (row) => {
     dramaId: row.dramaId,
     imageIds: row.imageIds || '',
     pageIds: row.pageIds || '',
+    creativeFileUrls: row.creativeFileUrls || '',
+    brandImageUrl: row.brandImageUrl || '',
+    creativeText: row.creativeText || '',
+    brandName: row.brandName || '',
+    buttonText: row.buttonText || '',
     jobNumber: row.jobNumber || ''
   }
   plans.value = []
@@ -513,6 +758,8 @@ const handleGeneratePlan = async () => {
   if (res.success) {
     ElMessage.success('生成中')
     console.log(res.data)
+    dialogVisible.value = false
+    router.push({ path: '/ad-plan-conf/detail', query: { id: res.data.id } })
   } else {
     ElMessage.error(res.message || '生成失败')
   }
@@ -537,7 +784,6 @@ const handleSubmit = async () => {
 
     if (res.success) {
       ElMessage.success(editingId.value ? '更新成功' : '创建成功')
-      dialogVisible.value = false
       await loadConfList()
     } else {
       ElMessage.error(res.message || '操作失败')
@@ -574,6 +820,101 @@ const loadAccountGroupList = async () => {
   }
 }
 
+// 打开文件选择器
+const openFileSelector = (type) => {
+  currentFileType.value = type
+  if (type === 'creative') {
+    fileSelectorTitle.value = '选择创意图片/视频'
+    fileSearchParams.value.businessType = 1
+  } else if (type === 'brand') {
+    fileSelectorTitle.value = '选择品牌形象图片'
+    fileSearchParams.value.businessType = 2
+  }
+  fileSelectorVisible.value = true
+  loadFileList()
+}
+
+// 加载文件列表
+const loadFileList = async () => {
+  fileLoading.value = true
+  try {
+    const res = await getFileList(fileSearchParams.value)
+    if (res.success) {
+      fileList.value = res.data.records || []
+      fileTotal.value = res.data.total || 0
+    } else {
+      ElMessage.error(res.message || '加载文件列表失败')
+    }
+  } catch (error) {
+    console.error('加载文件列表失败:', error)
+    ElMessage.error('加载文件列表失败')
+  } finally {
+    fileLoading.value = false
+  }
+}
+
+// 处理文件选择
+const handleFileSelectionChange = (selection) => {
+  selectedFiles.value = selection
+}
+
+// 确认文件选择
+const confirmFileSelection = () => {
+  if (selectedFiles.value.length === 0) {
+    ElMessage.warning('请至少选择一个文件')
+    return
+  }
+  
+  if (currentFileType.value === 'brand' && selectedFiles.value.length > 1) {
+    ElMessage.warning('品牌形象图片只能选择1张')
+    return
+  }
+  
+  const urls = selectedFiles.value.map(file => file.fileUrl).join(',')
+  
+  if (currentFileType.value === 'creative') {
+    formData.value.creativeFileUrls = urls
+  } else if (currentFileType.value === 'brand') {
+    formData.value.brandImageUrl = selectedFiles.value[0].fileUrl
+  }
+  
+  fileSelectorVisible.value = false
+  selectedFiles.value = []
+}
+
+// 移除文件
+const removeFile = (type, index) => {
+  if (type === 'creative') {
+    const urls = formData.value.creativeFileUrls.split(',')
+    urls.splice(index, 1)
+    formData.value.creativeFileUrls = urls.filter(url => url).join(',')
+  } else if (type === 'brand') {
+    formData.value.brandImageUrl = ''
+  }
+}
+
+// 获取文件名
+const getFileName = (url) => {
+  if (!url) return ''
+  const parts = url.split('/')
+  return parts[parts.length - 1] || url.substring(0, 20) + '...'
+}
+
+// 预览文件
+const previewFile = (url) => {
+  previewUrl.value = url
+  previewVisible.value = true
+}
+
+// 格式化文件大小
+const formatFileSize = (bytes) => {
+  if (!bytes) return '0 B'
+  const k = 1024
+  const sizes = ['B', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
+}
+
 // 监听分页变化
 watch([currentPage, pageSize], () => {
   loadConfList()

+ 61 - 59
portal/src/views/dashboard/index.vue

@@ -15,7 +15,7 @@
     <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="/oauth" 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">
+        <router-link to="/auth" 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 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
@@ -53,63 +53,6 @@
       </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 rounded-lg border" :class="{
-          'bg-green-50 border-green-200': systemStatus.serviceStatus === 'running',
-          'bg-red-50 border-red-200': systemStatus.serviceStatus !== 'running'
-        }">
-          <div class="p-2 rounded-full mr-4" :class="{
-            'bg-green-200': systemStatus.serviceStatus === 'running',
-            'bg-red-200': systemStatus.serviceStatus !== 'running'
-          }">
-            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" :class="{
-              'text-green-600': systemStatus.serviceStatus === 'running',
-              'text-red-600': systemStatus.serviceStatus !== 'running'
-            }" 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" :class="{
-              'text-green-800': systemStatus.serviceStatus === 'running',
-              'text-red-800': systemStatus.serviceStatus !== 'running'
-            }">{{ systemStatus.statusMessage }}</p>
-            <p class="text-sm" :class="{
-              'text-green-600': systemStatus.serviceStatus === 'running',
-              'text-red-600': systemStatus.serviceStatus !== 'running'
-            }">服务状态: {{ systemStatus.serviceStatus === 'running' ? '正常运行' : '异常' }}</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">{{ systemStatus.updateTime }}</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">{{ systemStatus.version }}</p>
-          </div>
-        </div>
-      </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">
@@ -208,6 +151,63 @@
         </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-1 md:grid-cols-3 gap-6">
+        <div class="flex items-center p-4 rounded-lg border" :class="{
+          'bg-green-50 border-green-200': systemStatus.serviceStatus === 'running',
+          'bg-red-50 border-red-200': systemStatus.serviceStatus !== 'running'
+        }">
+          <div class="p-2 rounded-full mr-4" :class="{
+            'bg-green-200': systemStatus.serviceStatus === 'running',
+            'bg-red-200': systemStatus.serviceStatus !== 'running'
+          }">
+            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" :class="{
+              'text-green-600': systemStatus.serviceStatus === 'running',
+              'text-red-600': systemStatus.serviceStatus !== 'running'
+            }" 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" :class="{
+              'text-green-800': systemStatus.serviceStatus === 'running',
+              'text-red-800': systemStatus.serviceStatus !== 'running'
+            }">{{ systemStatus.statusMessage }}</p>
+            <p class="text-sm" :class="{
+              'text-green-600': systemStatus.serviceStatus === 'running',
+              'text-red-600': systemStatus.serviceStatus !== 'running'
+            }">服务状态: {{ systemStatus.serviceStatus === 'running' ? '正常运行' : '异常' }}</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">{{ systemStatus.updateTime }}</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">{{ systemStatus.version }}</p>
+          </div>
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 
@@ -219,6 +219,8 @@ import {
   getSystemStatus,
   getRecentActivities
 } from '@/api/dashboard'
+import router from '@/router'
+
 
 // 统计数据
 const statistics = ref({
@@ -294,7 +296,7 @@ const loadRecentActivities = async () => {
 // 查看计划详情
 const viewPlanDetail = (confId) => {
   if (confId) {
-    window.open(`/ad-plan-conf/detail?id=${confId}`, '_blank')
+    router.push(`/ad-plan-conf/detail?id=${confId}`)
   }
 }
 

+ 1 - 1
portal/src/views/login/index.vue

@@ -116,7 +116,7 @@ const handleLogin = async () => {
         ElMessage.success(res.message || '登录成功')
 
         // 登录成功后直接跳转到个人中心
-        router.push('/profile')
+        router.push('/dashboard')
       } else {
         ElMessage.error(res.message || '登录失败')
       }

+ 516 - 0
portal/src/views/oss-file/index.vue

@@ -0,0 +1,516 @@
+<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="业务分类" prop="businessType">
+          <el-select @change="handleBusinessTypeChange" v-model="uploadForm.businessType" placeholder="请选择业务分类" style="width: 100%">
+            <el-option label="创意" :value="1"></el-option>
+            <el-option label="品牌形象" :value="2"></el-option>
+          </el-select>
+        </el-form-item>
+
+        <el-form-item label="文件类型" prop="fileType">
+          <el-select @change="handleFileTypeChange" v-model="uploadForm.fileType" placeholder="请选择文件类型" style="width: 100%" :disabled="uploadForm.businessType === 2">
+            <el-option label="图片" value="image"></el-option>
+            <el-option label="视频" value="video" v-if="uploadForm.businessType !== 2"></el-option>
+          </el-select>
+        </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="uploadFileList"
+            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">
+                支持各种文件格式,建议文件大小不超过100MB
+              </div>
+            </template>
+          </el-upload>
+        </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="loadFileList">
+          <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>
+
+      <!-- 筛选条件 -->
+      <div class="mb-6 p-4 bg-gray-50 rounded-lg">
+        <el-form :inline="true" :model="queryForm" class="flex flex-wrap gap-4">
+          <el-form-item label="业务分类">
+            <el-select v-model="queryForm.businessType" placeholder="全部分类" clearable style="width: 150px">
+              <el-option label="创意" :value="1"></el-option>
+              <el-option label="品牌形象" :value="2"></el-option>
+            </el-select>
+          </el-form-item>
+
+          <el-form-item label="文件类型">
+            <el-select v-model="queryForm.fileType" placeholder="全部类型" clearable style="width: 150px">
+              <el-option label="图片" value="image"></el-option>
+              <el-option label="视频" value="video"></el-option>
+            </el-select>
+          </el-form-item>
+
+          <el-form-item label="文件名">
+            <el-input v-model="queryForm.fileName" placeholder="请输入文件名" clearable style="width: 200px"></el-input>
+          </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>
+      </div>
+
+      <!-- 批量操作 -->
+      <div class="mb-4 flex items-center gap-3" v-if="selectedFiles.length > 0">
+        <span class="text-sm text-gray-600">已选择 {{ selectedFiles.length }} 项</span>
+        <el-button type="danger" size="small" @click="handleBatchDelete">批量删除</el-button>
+      </div>
+
+      <!-- 文件表格 -->
+      <el-table 
+        :data="fileList" 
+        v-loading="loading"
+        @selection-change="handleSelectionChange"
+        style="width: 100%"
+        :header-cell-style="{ background: '#f9fafb', color: '#374151', fontWeight: '600' }">
+        <el-table-column type="selection" width="55"></el-table-column>
+        
+        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        
+        <el-table-column prop="businessType" label="业务分类" width="120">
+          <template #default="{ row }">
+            <el-tag :type="row.businessType === 1 ? 'primary' : 'success'" size="small">
+              {{ row.businessType === 1 ? '创意' : '品牌形象' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        
+        <el-table-column label="文件预览" width="120">
+          <template #default="{ row }">
+            <div v-if="row.fileType === 'image'" class="w-20 h-20 overflow-hidden rounded border border-gray-200">
+              <img :src="row.fileUrl" alt="预览" class="w-full h-full object-cover cursor-pointer" @click="previewFile(row)">
+            </div>
+            <div v-else class="w-20 h-20 flex items-center justify-center bg-gray-100 rounded border border-gray-200">
+              <svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-gray-400" 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>
+          </template>
+        </el-table-column>
+        
+        <el-table-column prop="originalFileName" label="文件名" min-width="200" show-overflow-tooltip></el-table-column>
+        
+        <el-table-column prop="fileType" label="类型" width="100">
+          <template #default="{ row }">
+            <el-tag :type="getFileTypeColor(row.fileType)" size="small">{{ getFileTypeLabel(row.fileType) }}</el-tag>
+          </template>
+        </el-table-column>
+        
+        <el-table-column prop="fileSize" label="大小" width="120">
+          <template #default="{ row }">
+            {{ formatFileSize(row.fileSize) }}
+          </template>
+        </el-table-column>
+        
+        <el-table-column prop="folder" label="存储路径" width="150" show-overflow-tooltip></el-table-column>
+        
+        <el-table-column prop="createdByName" label="上传人" width="120"></el-table-column>
+        
+        <el-table-column prop="createdTime" label="上传时间" width="180">
+          <template #default="{ row }">
+            {{ formatDateTime(row.createdTime) }}
+          </template>
+        </el-table-column>
+        
+        <el-table-column label="操作" width="200" fixed="right">
+          <template #default="{ row }">
+            <div class="flex gap-2">
+              <el-button type="primary" size="small" link @click="copyUrl(row)">复制链接</el-button>
+              <el-button type="primary" size="small" link @click="downloadFile(row)">下载</el-button>
+              <el-button type="danger" size="small" link @click="handleDelete(row)">删除</el-button>
+            </div>
+          </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="handlePageChange">
+        </el-pagination>
+      </div>
+    </div>
+
+    <!-- 图片预览对话框 -->
+    <el-dialog v-model="previewVisible" title="图片预览" width="800px">
+      <div class="flex justify-center">
+        <img :src="previewUrl" alt="预览" style="max-width: 100%; max-height: 600px;">
+      </div>
+    </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 { uploadFile, getFileList, deleteFile, batchDeleteFiles } from '@/api/ossFile'
+
+const handleBusinessTypeChange = (value) => {
+  uploadForm.businessType = value
+  // 品牌形象只能选图片
+  if (value === 2) {
+    uploadForm.fileType = 'image'
+    uploadForm.folder = 'images/'
+  }
+}
+
+const handleFileTypeChange = (value) => {
+  uploadForm.fileType = value
+  if (value === 'image') {
+    uploadForm.folder = 'images/'
+  } else if (value === 'video') {
+    uploadForm.folder = 'videos/'
+  }
+}
+// 上传表单
+const uploadFormRef = ref(null)
+const uploadRef = ref(null)
+const uploadForm = reactive({
+  businessType: 1,
+  fileType: 'image',
+  folder: 'images/',
+  file: null
+})
+
+const uploadRules = {
+  businessType: [{ required: true, message: '请选择业务分类', trigger: 'change' }],
+  fileType: [{ required: true, message: '请选择文件类型', trigger: 'change' }],
+  file: [{ required: true, message: '请选择文件', trigger: 'change' }]
+}
+
+const uploadFileList = ref([])
+const uploading = ref(false)
+
+// 查询表单
+const queryForm = reactive({
+  businessType: null,
+  fileType: '',
+  fileName: ''
+})
+
+// 文件列表
+const fileList = ref([])
+const loading = ref(false)
+const selectedFiles = ref([])
+
+// 分页
+const pagination = reactive({
+  page: 1,
+  size: 10,
+  total: 0
+})
+
+// 预览
+const previewVisible = ref(false)
+const previewUrl = ref('')
+
+// 文件选择
+const handleFileChange = (file) => {
+  uploadForm.file = file.raw
+  uploadFileList.value = [file]
+}
+
+const handleExceed = () => {
+  ElMessage.warning('只能上传一个文件')
+}
+
+// 提交上传
+const submitUpload = async () => {
+  if (!uploadFormRef.value) return
+  
+  await uploadFormRef.value.validate(async (valid) => {
+    if (!valid) return
+    
+    if (!uploadForm.file) {
+      ElMessage.warning('请选择文件')
+      return
+    }
+
+    uploading.value = true
+    const formData = new FormData()
+    formData.append('file', uploadForm.file)
+    formData.append('folder', uploadForm.folder)
+    formData.append('fileType', uploadForm.fileType)
+    formData.append('businessType', uploadForm.businessType)
+
+    try {
+      const res = await uploadFile(formData)
+      if (res.success) {
+        ElMessage.success('文件上传成功')
+        resetForm()
+        loadFileList()
+      } else {
+        ElMessage.error(res.message || '上传失败')
+      }
+    } catch (error) {
+      console.error('上传失败:', error)
+      ElMessage.error('上传失败: ' + (error.message || '未知错误'))
+    } finally {
+      uploading.value = false
+    }
+  })
+}
+
+// 重置表单
+const resetForm = () => {
+  uploadFormRef.value?.resetFields()
+  uploadRef.value?.clearFiles()
+  uploadFileList.value = []
+  uploadForm.file = null
+}
+
+// 加载文件列表
+const loadFileList = async () => {
+  loading.value = true
+  try {
+    const res = await getFileList({
+      page: pagination.page,
+      size: pagination.size,
+      fileType: queryForm.fileType,
+      businessType: queryForm.businessType,
+      fileName: queryForm.fileName
+    })
+    if (res.success) {
+      fileList.value = res.data.records
+      pagination.total = res.data.total
+    } else {
+      ElMessage.error(res.message || '查询失败')
+    }
+  } catch (error) {
+    console.error('查询失败:', error)
+    ElMessage.error('查询失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+// 查询
+const handleQuery = () => {
+  pagination.page = 1
+  loadFileList()
+}
+
+// 重置查询
+const handleReset = () => {
+  queryForm.businessType = null
+  queryForm.fileType = ''
+  queryForm.fileName = ''
+  handleQuery()
+}
+
+// 选择变化
+const handleSelectionChange = (selection) => {
+  selectedFiles.value = selection
+}
+
+// 删除文件
+const handleDelete = (row) => {
+  ElMessageBox.confirm('确定要删除该文件吗?', '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(async () => {
+    try {
+      const res = await deleteFile(row.id)
+      if (res.success) {
+        ElMessage.success('删除成功')
+        loadFileList()
+      } else {
+        ElMessage.error(res.message || '删除失败')
+      }
+    } catch (error) {
+      console.error('删除失败:', error)
+      ElMessage.error('删除失败')
+    }
+  }).catch(() => {})
+}
+
+// 批量删除
+const handleBatchDelete = () => {
+  ElMessageBox.confirm(`确定要删除选中的 ${selectedFiles.value.length} 个文件吗?`, '提示', {
+    confirmButtonText: '确定',
+    cancelButtonText: '取消',
+    type: 'warning'
+  }).then(async () => {
+    try {
+      const fileIds = selectedFiles.value.map(item => item.id)
+      const res = await batchDeleteFiles(fileIds)
+      if (res.code === 200) {
+        ElMessage.success('批量删除成功')
+        loadFileList()
+      } else {
+        ElMessage.error(res.message || '批量删除失败')
+      }
+    } catch (error) {
+      console.error('批量删除失败:', error)
+      ElMessage.error('批量删除失败')
+    }
+  }).catch(() => {})
+}
+
+// 复制URL
+const copyUrl = (row) => {
+  navigator.clipboard.writeText(row.fileUrl).then(() => {
+    ElMessage.success('链接已复制到剪贴板')
+  }).catch(() => {
+    ElMessage.error('复制失败')
+  })
+}
+
+// 下载文件
+const downloadFile = (row) => {
+  window.open(row.fileUrl, '_blank')
+}
+
+// 预览文件
+const previewFile = (row) => {
+  if (row.fileType === 'image') {
+    previewUrl.value = row.fileUrl
+    previewVisible.value = true
+  } else {
+    window.open(row.fileUrl, '_blank')
+  }
+}
+
+// 分页变化
+const handleSizeChange = (size) => {
+  pagination.size = size
+  loadFileList()
+}
+
+const handlePageChange = (page) => {
+  pagination.page = page
+  loadFileList()
+}
+
+// 工具函数
+const getFileTypeLabel = (type) => {
+  const labels = {
+    image: '图片',
+    video: '视频',
+    document: '文档',
+    other: '其他'
+  }
+  return labels[type] || type
+}
+
+const getFileTypeColor = (type) => {
+  const colors = {
+    image: 'success',
+    video: 'primary',
+    document: 'warning',
+    other: 'info'
+  }
+  return colors[type] || 'info'
+}
+
+const formatFileSize = (bytes) => {
+  if (!bytes) return '0 B'
+  const k = 1024
+  const sizes = ['B', 'KB', 'MB', 'GB']
+  const i = Math.floor(Math.log(bytes) / Math.log(k))
+  return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]
+}
+
+const formatDateTime = (dateTime) => {
+  if (!dateTime) return ''
+  return dateTime.replace('T', ' ')
+}
+
+// 初始化
+onMounted(() => {
+  loadFileList()
+})
+</script>
+
+<style scoped>
+.btn-primary {
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  border: none;
+  color: white;
+}
+
+.btn-primary:hover {
+  background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
+}
+
+.btn-secondary {
+  border-color: #e5e7eb;
+  color: #6b7280;
+}
+
+.btn-secondary:hover {
+  background-color: #f9fafb;
+}
+
+:deep(.el-upload-dragger) {
+  border: 2px dashed #d1d5db;
+  border-radius: 8px;
+  background-color: #f9fafb;
+  transition: all 0.3s;
+}
+
+:deep(.el-upload-dragger:hover) {
+  border-color: #667eea;
+  background-color: #f3f4f6;
+}
+</style>

+ 23 - 0
portal/src/views/user/index.vue

@@ -102,6 +102,11 @@
             {{ formatDateTime(row.createTime) }}
           </template>
         </el-table-column>
+        <el-table-column label="操作" width="120">
+          <template #default="{ row }">
+            <el-button v-if="row.jobNumber === jobNumber" size="small" type="primary" @click="handleGotoProfile">去认证</el-button>
+          </template>
+        </el-table-column>
       </el-table>
     </div>
 
@@ -170,14 +175,31 @@ import {
   deleteUser,
   getUserAuthUrl
 } from '@/api/user'
+import router from '@/router'
 
 // 数据
 const loading = ref(false)
 const userList = ref([])
 
+// 用户信息
+const jobNumber = ref('')
+const userInfo = ref(null)
+
+// 加载用户信息
+const loadUserInfo = () => {
+  const storedUserInfo = localStorage.getItem('userInfo')
+  if (storedUserInfo) {
+    userInfo.value = JSON.parse(storedUserInfo)
+    jobNumber.value = userInfo.value.jobNumber || '未知'
+  }
+}
+
 // 对话框
 const detailDialogVisible = ref(false)
 
+const handleGotoProfile = () => {
+  router.push({ path: '/profile' })
+}
 
 // 计算属性
 const authenticatedCount = computed(() => {
@@ -225,6 +247,7 @@ const isTokenValid = (user) => {
 // 生命周期
 onMounted(() => {
   loadUserList()
+  loadUserInfo()
 })
 </script>
 

+ 50 - 0
src/main/java/com/moka/gdtauto/config/AliyunOssConfig.java

@@ -0,0 +1,50 @@
+package com.moka.gdtauto.config;
+
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.OSSClientBuilder;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 阿里云OSS配置类
+ */
+@Configuration
+@ConfigurationProperties(prefix = "aliyun.oss")
+@Data
+public class AliyunOssConfig {
+    
+    /**
+     * OSS访问密钥ID
+     */
+    private String accessKeyId;
+    
+    /**
+     * OSS访问密钥Secret
+     */
+    private String accessKeySecret;
+    
+    /**
+     * OSS服务端点(本地公网:oss-cn-shanghai.aliyuncs.com,上线私网:oss-cn-shanghai-internal.aliyuncs.com)
+     */
+    private String endpoint;
+    
+    /**
+     * Bucket名称
+     */
+    private String bucketName;
+    
+    /**
+     * CDN域名
+     */
+    private String cdnDomain;
+    
+    /**
+     * 创建OSS客户端Bean
+     */
+    @Bean
+    public OSS ossClient() {
+        return new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
+    }
+}

+ 33 - 12
src/main/java/com/moka/gdtauto/controller/AdPlanConfController.java

@@ -56,8 +56,8 @@ public class AdPlanConfController {
                     new LambdaQueryWrapper<AccountGroupAccountRelationship>()
                             .eq(AccountGroupAccountRelationship::getGroupId, accountGroupId));
             String confName = request.getName();
-            String imageIds = request.getImageIds();
-            String[] imageIdArray = imageIds.split(",");
+            String creativeFileUrls = request.getCreativeFileUrls();
+            String[] creativeUrlArray = creativeFileUrls.split(",");
             List<AdPlan>  plans = new ArrayList<>();
              int i = 0;
             for (AccountGroupAccountRelationship relationship : relationships) {
@@ -86,9 +86,15 @@ public class AdPlanConfController {
                         plans.add(adPlan);
                         List<AdPlanCreative> adPlanCreatives = new ArrayList<>();
                         adPlan.setAdCreatives(adPlanCreatives);
-                        for (String imageId : imageIdArray) {
+                        for (String creativeUrl : creativeUrlArray) {
                             AdPlanCreative adPlanCreative = new AdPlanCreative();
-                            adPlanCreative.setImageId(Long.valueOf(imageId));
+                            adPlanCreative.setCreativeFileUrl(creativeUrl);
+                            adPlanCreative.setCreativeText(request.getCreativeText());
+                            adPlanCreative.setImageId(0L);
+                            adPlanCreative.setBrandImageId(0L);
+                            adPlanCreative.setBrandName(request.getBrandName());
+                            adPlanCreative.setBrandImageUrl(request.getBrandImageUrl());
+                            adPlanCreative.setButtonText(request.getButtonText());
                             adPlanCreative.setPageIds(request.getPageIds());
                             adPlanCreatives.add(adPlanCreative);
                         }
@@ -104,7 +110,7 @@ public class AdPlanConfController {
     }
 
     @PostMapping("/generate-plan")
-    public Result<Void> generatePlan(
+    public Result<AdPlanConf> generatePlan(
             @RequestBody UpdateAdPlanConfRequest request,
             @AuthUser SysUser sysUser) {
         try {
@@ -113,17 +119,22 @@ public class AdPlanConfController {
                     new LambdaQueryWrapper<AccountGroupAccountRelationship>()
                             .eq(AccountGroupAccountRelationship::getGroupId, accountGroupId));
                             Long confId = request.getId();
+            AdPlanConf adPlanConf = new AdPlanConf();
             if (confId == null) {
-                AdPlanConf adPlanConf = new AdPlanConf();
                 BeanUtils.copyProperties(request, adPlanConf);
                 adPlanConf.setUserId(sysUser.getId());
                 adPlanConf.setJobNumber(sysUser.getJobNumber());
                 confId = adPlanConfService.createAdPlanConf(adPlanConf);
+            } else {
+                BeanUtils.copyProperties(request, adPlanConf);
+                adPlanConf.setUserId(sysUser.getId());
+                adPlanConf.setJobNumber(sysUser.getJobNumber());
+                adPlanConfService.updateAdPlanConf(adPlanConf);
             }
 
             String confName = request.getName();
-            String imageIds = request.getImageIds();
-            String[] imageIdArray = imageIds.split(",");
+            String creativeFileUrls = request.getCreativeFileUrls();
+            String[] creativeUrlArray = creativeFileUrls.split(",");
             List<AdPlan>  plans = new ArrayList<>();
              int i = 0;
             for (AccountGroupAccountRelationship relationship : relationships) {
@@ -143,6 +154,7 @@ public class AdPlanConfController {
                         String planName = confName + "-" + i;
                         adPlan.setAccountId(accountId);
                         adPlan.setAdgroupName(planName);
+                        adPlan.setAdgroupId(0L);
                         adPlan.setAdType(request.getAdType());
                         adPlan.setTarget(target);
                         adPlan.setSite(site);
@@ -153,12 +165,21 @@ public class AdPlanConfController {
                         plans.add(adPlan);
                         List<AdPlanCreative> adPlanCreatives = new ArrayList<>();
                         adPlan.setAdCreatives(adPlanCreatives);
-                        for (String imageId : imageIdArray) {
+                        int j = 0;
+                        for (String creativeUrl : creativeUrlArray) {
                             AdPlanCreative adPlanCreative = new AdPlanCreative();
-                            String creativeName = "测试创意-" + i;
+                            String creativeName = "测试创意-" + j++;
+                            adPlanCreative.setCreativeId(0L);
                             adPlanCreative.setCreativeName(creativeName);
                             adPlanCreative.setAdPlanConfId(confId);
-                            adPlanCreative.setImageId(Long.valueOf(imageId));
+                            adPlanCreative.setAdgroupId(0L);
+                            adPlanCreative.setCreativeFileUrl(creativeUrl);
+                            adPlanCreative.setImageId(0L);
+                            adPlanCreative.setCreativeText(request.getCreativeText());
+                            adPlanCreative.setBrandName(request.getBrandName());
+                            adPlanCreative.setBrandImageUrl(request.getBrandImageUrl());
+                            adPlanCreative.setBrandImageId(0L);
+                            adPlanCreative.setButtonText(request.getButtonText());
                             adPlanCreative.setPageIds(request.getPageIds());
                             adPlanCreatives.add(adPlanCreative);
                         }
@@ -171,7 +192,7 @@ public class AdPlanConfController {
             //异步对接腾讯接口
             adPlanConfService.asyncTencentInterface(plans);
 
-            return Result.success("创建广告计划成功;异步执行接口对接中");
+            return Result.success(adPlanConf, "创建广告计划成功;异步执行接口对接中");
         } catch (Exception e) {
             log.error("创建广告计划失败", e);
             return Result.fail("创建广告计划失败: " + e.getMessage());

+ 150 - 0
src/main/java/com/moka/gdtauto/controller/OssFileController.java

@@ -0,0 +1,150 @@
+package com.moka.gdtauto.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.moka.gdtauto.annotation.AuthUser;
+import com.moka.gdtauto.common.Result;
+import com.moka.gdtauto.entity.OssFile;
+import com.moka.gdtauto.entity.SysUser;
+import com.moka.gdtauto.service.OssFileService;
+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 org.springframework.web.multipart.MultipartFile;
+
+/**
+ * OSS文件管理Controller
+ *
+ * @author moka
+ * @since 2026-02-26
+ */
+@Tag(name = "OSS文件管理", description = "OSS文件的上传、删除及查询")
+@Slf4j
+@RestController
+@RequestMapping("/api/oss-file")
+@RequiredArgsConstructor
+public class OssFileController {
+
+    private final OssFileService ossFileService;
+
+    /**
+     * 上传文件
+     */
+    @Operation(summary = "上传文件", description = "上传文件到OSS并记录")
+    @PostMapping("/upload")
+    public Result<OssFile> uploadFile(
+            @Parameter(description = "文件", required = true)
+            @RequestParam("file") MultipartFile file,
+            @Parameter(description = "存储文件夹路径,如:images/、videos/")
+            @RequestParam(value = "folder", required = false, defaultValue = "") String folder,
+            @Parameter(description = "文件类型,如:image、video、document")
+            @RequestParam(value = "fileType", required = false, defaultValue = "document") String fileType,
+            @Parameter(description = "业务分类(1:创意 2:品牌形象)")
+            @RequestParam(value = "businessType", required = false) Integer businessType,
+            @AuthUser SysUser sysUser) {
+        try {
+            if (file.isEmpty()) {
+                return Result.fail("文件不能为空");
+            }
+            
+            OssFile ossFile = ossFileService.uploadFile(
+                    file, 
+                    folder, 
+                    fileType,
+                    businessType,
+                    sysUser.getId(), 
+                    sysUser.getName()
+            );
+            
+            return Result.success(ossFile, "文件上传成功");
+        } catch (Exception e) {
+            log.error("文件上传失败", e);
+            return Result.fail("文件上传失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 分页查询文件列表
+     */
+    @Operation(summary = "分页查询文件列表", description = "根据条件分页查询文件列表")
+    @GetMapping("/list")
+    public Result<Page<OssFile>> listFiles(
+            @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
+            @Parameter(description = "每页数量") @RequestParam(defaultValue = "10") Long size,
+            @Parameter(description = "文件类型") @RequestParam(required = false) String fileType,
+            @Parameter(description = "业务分类(1:创意 2:品牌形象)") @RequestParam(required = false) Integer businessType,
+            @Parameter(description = "文件名(模糊查询)") @RequestParam(required = false) String fileName,
+            @Parameter(description = "创建人id") @RequestParam(required = false) Long userId) {
+        try {
+            Page<OssFile> result = ossFileService.listFiles(page, size, fileType, businessType, fileName, userId);
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("查询文件列表失败", e);
+            return Result.fail("查询文件列表失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 根据ID查询文件详情
+     */
+    @Operation(summary = "查询文件详情", description = "根据ID查询文件详情")
+    @GetMapping("/{id}")
+    public Result<OssFile> getById(
+            @Parameter(description = "文件ID", required = true)
+            @PathVariable Long id) {
+        try {
+            OssFile ossFile = ossFileService.getById(id);
+            if (ossFile == null) {
+                return Result.fail("文件不存在");
+            }
+            return Result.success(ossFile);
+        } catch (Exception e) {
+            log.error("查询文件详情失败,ID: {}", id, e);
+            return Result.fail("查询文件详情失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 删除文件
+     */
+    @Operation(summary = "删除文件", description = "删除OSS文件及数据库记录")
+    @DeleteMapping("/{id}")
+    public Result<Void> deleteFile(
+            @Parameter(description = "文件ID", required = true)
+            @PathVariable Long id) {
+        try {
+            boolean success = ossFileService.deleteFile(id);
+            if (success) {
+                return Result.success("文件删除成功");
+            } else {
+                return Result.fail("文件不存在或删除失败");
+            }
+        } catch (Exception e) {
+            log.error("文件删除失败,ID: {}", id, e);
+            return Result.fail("文件删除失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 批量删除文件
+     */
+    @Operation(summary = "批量删除文件", description = "批量删除多个文件")
+    @DeleteMapping("/batch")
+    public Result<Void> batchDeleteFiles(
+            @Parameter(description = "文件ID数组", required = true)
+            @RequestParam Long[] fileIds) {
+        try {
+            boolean success = ossFileService.batchDeleteFiles(fileIds);
+            if (success) {
+                return Result.success("批量删除成功");
+            } else {
+                return Result.fail("批量删除失败,部分文件可能删除失败");
+            }
+        } catch (Exception e) {
+            log.error("批量删除文件失败", e);
+            return Result.fail("批量删除文件失败: " + e.getMessage());
+        }
+    }
+}

+ 15 - 0
src/main/java/com/moka/gdtauto/controller/vo/CreateAdPlanConfRequest.java

@@ -46,6 +46,21 @@ public class CreateAdPlanConfRequest {
     @Schema(description = "落地页IDs,多个用逗号分隔", example = "1,2,3")
     private String pageIds;
 
+    @Schema(description = "创意图片/视频文件URL,多个用逗号分隔")
+    private String creativeFileUrls;
+
+    @Schema(description = "品牌形象图片文件URL,单张")
+    private String brandImageUrl;
+
+    @Schema(description = "创意文案")
+    private String creativeText;
+
+    @Schema(description = "品牌名称")
+    private String brandName;
+
+    @Schema(description = "按钮文案")
+    private String buttonText;
+
     @Schema(description = "工号", example = "A001")
     private String jobNumber;
 }

+ 15 - 0
src/main/java/com/moka/gdtauto/controller/vo/UpdateAdPlanConfRequest.java

@@ -46,6 +46,21 @@ public class UpdateAdPlanConfRequest {
     @Schema(description = "落地页IDs,多个用逗号分隔", example = "1,2,3")
     private String pageIds;
 
+    @Schema(description = "创意图片/视频文件URL,多个用逗号分隔")
+    private String creativeFileUrls;
+
+    @Schema(description = "品牌形象图片文件URL,单张")
+    private String brandImageUrl;
+
+    @Schema(description = "创意文案")
+    private String creativeText;
+
+    @Schema(description = "品牌名称")
+    private String brandName;
+
+    @Schema(description = "按钮文案")
+    private String buttonText;
+
     @Schema(description = "工号", example = "A001")
     private String jobNumber;
 }

+ 25 - 0
src/main/java/com/moka/gdtauto/entity/AdPlanConf.java

@@ -67,6 +67,31 @@ public class AdPlanConf {
      */
     private Long dramaId;
 
+    /**
+     * 创意图片/视频文件URL(从oss_file表获取,多个用逗号分隔)
+     */
+    private String creativeFileUrls;
+
+    /**
+     * 品牌形象图片文件URL(从oss_file表获取,单张)
+     */
+    private String brandImageUrl;
+
+    /**
+     * 创意文案
+     */
+    private String creativeText;
+
+    /**
+     * 品牌名称
+     */
+    private String brandName;
+
+    /**
+     * 按钮文案
+     */
+    private String buttonText;
+
    
 
     /**

+ 6 - 0
src/main/java/com/moka/gdtauto/entity/AdPlanCreative.java

@@ -54,6 +54,12 @@ public class AdPlanCreative {
      * 图片ID
      */
     private Long imageId;
+    private String creativeFileUrl;
+    private String creativeText;
+    private String brandName;
+    private String brandImageUrl;
+    private Long brandImageId;
+    private String buttonText;
 
     /**
      * 落地页ID

+ 90 - 0
src/main/java/com/moka/gdtauto/entity/OssFile.java

@@ -0,0 +1,90 @@
+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;
+
+/**
+ * OSS文件管理实体类
+ *
+ * @author moka
+ * @since 2026-02-26
+ */
+@Data
+@TableName("oss_file")
+public class OssFile {
+
+    /**
+     * 主键ID
+     */
+    @TableId(value = "id", type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 文件名
+     */
+    private String fileName;
+
+    /**
+     * 原始文件名
+     */
+    private String originalFileName;
+
+    /**
+     * 文件类型(image/video/document等)
+     */
+    private String fileType;
+
+    /**
+     * 业务分类(1:创意 2:品牌形象)
+     */
+    private Integer businessType;
+
+    /**
+     * 文件大小(字节)
+     */
+    private Long fileSize;
+
+    /**
+     * 文件URL
+     */
+    private String fileUrl;
+
+    /**
+     * OSS对象Key
+     */
+    private String objectKey;
+
+    /**
+     * 存储文件夹路径
+     */
+    private String folder;
+
+    /**
+     * 文件扩展名
+     */
+    private String fileExtension;
+
+    /**
+     * 创建人ID
+     */
+    private Long createdBy;
+
+    /**
+     * 创建人姓名
+     */
+    private String createdByName;
+
+    /**
+     * 创建时间
+     */
+    private LocalDateTime createdTime;
+
+    /**
+     * 更新时间
+     */
+    private LocalDateTime updatedTime;
+}

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

@@ -0,0 +1,15 @@
+package com.moka.gdtauto.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.moka.gdtauto.entity.OssFile;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * OSS文件Mapper
+ *
+ * @author moka
+ * @since 2026-02-26
+ */
+@Mapper
+public interface OssFileMapper extends BaseMapper<OssFile> {
+}

+ 57 - 0
src/main/java/com/moka/gdtauto/service/OssFileService.java

@@ -0,0 +1,57 @@
+package com.moka.gdtauto.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.moka.gdtauto.entity.OssFile;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * OSS文件Service接口
+ *
+ * @author moka
+ * @since 2026-02-26
+ */
+public interface OssFileService extends IService<OssFile> {
+
+    /**
+     * 上传文件
+     *
+     * @param file 文件
+     * @param folder 存储文件夹
+     * @param fileType 文件类型
+     * @param businessType 业务分类(1:创意 2:品牌形象)
+     * @param userId 用户ID
+     * @param userName 用户姓名
+     * @return 文件记录
+     */
+    OssFile uploadFile(MultipartFile file, String folder, String fileType, Integer businessType, Long userId, String userName);
+
+    /**
+     * 删除文件(同时删除OSS和数据库记录)
+     *
+     * @param fileId 文件ID
+     * @return 是否成功
+     */
+    boolean deleteFile(Long fileId);
+
+    /**
+     * 批量删除文件
+     *
+     * @param fileIds 文件ID列表
+     * @return 是否全部成功
+     */
+    boolean batchDeleteFiles(Long[] fileIds);
+
+    /**
+     * 分页查询文件列表
+     *
+     * @param page 页码
+     * @param size 每页数量
+     * @param fileType 文件类型
+     * @param businessType 业务分类
+     * @param fileName 文件名(模糊查询)
+     * @param userId 创建人id
+     * @return 分页结果
+     */
+    Page<OssFile> listFiles(Long page, Long size, String fileType, Integer businessType, String fileName, Long userId);
+}

+ 263 - 22
src/main/java/com/moka/gdtauto/service/impl/AdPlanConfServiceImpl.java

@@ -35,6 +35,16 @@ import com.tencent.ads.v3.TencentAds;
 import com.moka.gdtauto.util.AdgroupsAddReqPool;
 import com.moka.gdtauto.common.AdPlanStatus;
 
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.math.BigInteger;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.file.Files;
+import java.security.MessageDigest;
+import java.util.Base64;
+
 
 
 /**
@@ -190,8 +200,12 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
                 
                 if (plan.getAdgroupId() > 0 || plan.getStatus() == AdPlanStatus.SUCCESS) {
                      // 创建动态创意
-                createDynamicCreatives(plan, jobNumber, 33453856L);
-                continue;
+                    createDynamicCreatives(plan, jobNumber, 33453856L);
+                    plan.setFailReason("");
+                    plan.setStatus(AdPlanStatus.SUCCESS);
+                    plan.setSuccessTime(LocalDateTime.now());
+                    adPlanService.updateById(plan);
+                    continue;
                 }
                     
 
@@ -230,7 +244,9 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
                 adPlanService.updateById(plan);
                 
                 // 创建动态创意
-                createDynamicCreatives(plan, jobNumber, 33453856L);
+                if (plan.getAdgroupId() > 0) {
+                    createDynamicCreatives(plan, jobNumber, 33453856L);
+                }
                 
                 log.info("广告计划处理成功,planId={}, adgroupId={}", plan.getId(), plan.getAdgroupId());
                 
@@ -301,7 +317,44 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
                 creative.setStatus(AdPlanStatus.IN_PROGRESS);
                 creative.setAdgroupId(plan.getAdgroupId());
                 adPlanCreativeService.updateById(creative);
+
+                // 上传创意文件的到imageId
+                if (creative.getImageId() == 0) {
+                    String creativeFileUrl = creative.getCreativeFileUrl();
+                    if (creativeFileUrl != null && !creativeFileUrl.isEmpty()) {
+                        try {
+                            log.info("开始上传创意图片,URL: {}", creativeFileUrl);
+                            Long imageId = uploadImageFromUrl(tencentAds, plan.getAccountId(), creativeFileUrl, "创意图片-" + creative.getCreativeName());
+                            creative.setImageId(imageId);
+                            adPlanCreativeService.updateById(creative);
+                            log.info("创意图片上传成功,imageId: {}", imageId);
+                        } catch (Exception e) {
+                            log.error("创意图片上传失败", e);
+                            throw new RuntimeException("创意图片上传失败: " + e.getMessage(), e);
+                        }
+                    }
+                }
                 
+                if (creative.getBrandImageId() == 0) {
+                    // 上传品牌形象
+                    String brandImageUrl = creative.getBrandImageUrl();
+                    String brandName = creative.getBrandName();
+                    if (brandImageUrl != null && !brandImageUrl.isEmpty() 
+                        && brandName != null && !brandName.isEmpty()) {
+                        try {
+                            log.info("开始上传品牌形象,URL: {}, 品牌名称: {}", brandImageUrl, brandName);
+                            Long brandImageId = uploadBrandFromUrl(tencentAds, plan.getAccountId(), brandImageUrl, brandName);
+                            creative.setBrandImageId(brandImageId);
+                            adPlanCreativeService.updateById(creative);
+                            log.info("品牌形象上传成功,brandImageId: {}", brandImageId);
+                        } catch (Exception e) {
+                            log.error("品牌形象上传失败", e);
+                            throw new RuntimeException("品牌形象上传失败: " + e.getMessage(), e);
+                        }
+                    }
+                }
+                
+
                 // 构建动态创意请求
                 DynamicCreativesAddRequest request = buildDynamicCreativeRequest(plan, creative);
                 
@@ -342,22 +395,22 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
         CreativeComponents components = new CreativeComponents();
         
         // 标题组件 (固定标题)
-        components.setTitle(buildTitleComponents());
+        components.setTitle(buildTitleComponents(creative.getCreativeText()));
         
         // 文案组件 (固定文案)
-        components.setDescription(buildDescriptionComponents());
+        components.setDescription(buildDescriptionComponents(creative.getCreativeText()));
         
         // 图片组件 (从 creative.imageId)
         components.setImage(buildImageComponents(creative.getImageId()));
         
         // 品牌形象 (固定品牌)
-        components.setBrand(buildBrandComponents());
+        components.setBrand(buildBrandComponents(creative.getBrandImageId(), creative.getBrandName()));
         
         // 落地页 (从 creative.pageIds, 逗号分隔)
         components.setMainJumpInfo(buildJumpInfoComponents(creative.getPageIds()));
         
         // 行动按钮 (数量与落地页一致, 文案统一)
-        components.setActionButton(buildActionButtonComponents(creative.getPageIds()));
+        components.setActionButton(buildActionButtonComponents(creative.getPageIds(), creative.getButtonText()));
         
         request.setCreativeComponents(components);
         return request;
@@ -366,12 +419,12 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
     /**
      * 构建标题组件
      */
-    private List<TitleComponent> buildTitleComponents() {
+    private List<TitleComponent> buildTitleComponents(String creativeText) {
         List<TitleComponent> titles = new ArrayList<>();
         
         TitleComponent title1 = new TitleComponent();
         TitleStruct titleValue1 = new TitleStruct();
-        titleValue1.setContent("情感美文点点看");
+        titleValue1.setContent(creativeText);
         title1.setValue(titleValue1);
         titles.add(title1);
         
@@ -381,12 +434,12 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
     /**
      * 构建文案组件
      */
-    private List<DescriptionComponent> buildDescriptionComponents() {
+    private List<DescriptionComponent> buildDescriptionComponents(String creativeText) {
         List<DescriptionComponent> descriptions = new ArrayList<>();
         
         DescriptionComponent desc1 = new DescriptionComponent();
         DescriptionStruct descValue1 = new DescriptionStruct();
-        descValue1.setContent("情感美文点点看");
+        descValue1.setContent(creativeText);
         desc1.setValue(descValue1);
         descriptions.add(desc1);
         
@@ -398,7 +451,6 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
      */
     private List<ImageComponent> buildImageComponents(Long imageId) {
         List<ImageComponent> images = new ArrayList<>();
-        
         ImageComponent image = new ImageComponent();
         ImageStruct imageValue = new ImageStruct();
         imageValue.setImageId(String.valueOf(imageId));
@@ -411,15 +463,14 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
     /**
      * 构建品牌组件
      */
-    private List<BrandComponent> buildBrandComponents() {
+    private List<BrandComponent> buildBrandComponents(Long brandImageId, String brandName) {
         List<BrandComponent> brands = new ArrayList<>();
         
         BrandComponent brand = new BrandComponent();
-        brand.setComponentId(44579024220L);
-        // BrandStruct brandValue = new BrandStruct();
-        // brandValue.setBrandName("情感美文");
-        // brandValue.setBrandImageId("30026044695");
-        // brand.setValue(brandValue);
+        BrandStruct brandValue = new BrandStruct();
+        brandValue.setBrandName(brandName);
+        brandValue.setBrandImageId(String.valueOf(brandImageId));
+        brand.setValue(brandValue);
         brands.add(brand);
         
         return brands;
@@ -457,7 +508,7 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
     /**
      * 构建行动按钮组件
      */
-    private List<ActionButtonComponent> buildActionButtonComponents(String pageIds) {
+    private List<ActionButtonComponent> buildActionButtonComponents(String pageIds, String buttonText) {
         List<ActionButtonComponent> actionButtons = new ArrayList<>();
         
         if (pageIds == null || pageIds.isEmpty()) {
@@ -468,7 +519,7 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
         for (int i = 0; i < pageIdArray.length; i++) {
             ActionButtonComponent actionButton = new ActionButtonComponent();
             ActionButtonStruct actionButtonValue = new ActionButtonStruct();
-            actionButtonValue.setButtonText("查看详情");
+            actionButtonValue.setButtonText(buttonText);
             actionButton.setValue(actionButtonValue);
             actionButtons.add(actionButton);
         }
@@ -488,14 +539,14 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
         // 更新配置状态
         AdPlanConf conf = this.getById(confId);
         if (conf == null) {
-            log.error("未找到广告计划配置,confId={}", confId);
+            log.warn("未找到广告计划配置,confId={}", confId);
             return;
         }
         
         if (failCount == 0) {
             conf.setStatus(AdPlanStatus.SUCCESS); // 全部成功
             conf.setSuccessTime(LocalDateTime.now());
-            conf.setFailReason(null);
+            conf.setFailReason("");
         } else if (successCount == 0) {
             conf.setStatus(AdPlanStatus.FAILED); // 全部失败
             conf.setFailReason("所有广告创建失败");
@@ -556,4 +607,194 @@ public class AdPlanConfServiceImpl extends ServiceImpl<AdPlanConfMapper, AdPlanC
             return 1;
         }
     }
+    
+    /**
+     * 从URL下载图片并上传到腾讯广告
+     * 
+     * @param tencentAds 腾讯广告SDK实例
+     * @param accountId 账户ID
+     * @param imageUrl 图片URL
+     * @param description 图片描述
+     * @return 图片ID
+     * @throws Exception 上传失败
+     */
+    private Long uploadImageFromUrl(TencentAds tencentAds, Long accountId, String imageUrl, String description) throws Exception {
+        File tempFile = null;
+        try {
+            // 1. 从URL下载图片到临时文件
+            tempFile = downloadFileFromUrl(imageUrl);
+            
+            // 2. 读取文件并转Base64
+            byte[] imageBytes = Files.readAllBytes(tempFile.toPath());
+            String base64Image = Base64.getEncoder().encodeToString(imageBytes);
+            
+            // 3. 计算MD5签名
+            String signature = calculateMD5(imageBytes);
+            
+            // 4. 调用腾讯广告API上传图片
+            ImagesAddResponseData response = tencentAds.images()
+                .imagesAdd(
+                    "UPLOAD_TYPE_BYTES",  // uploadType
+                    signature,             // signature
+                    accountId,             // accountId
+                    null,                  // organizationId
+                    null,                  // file
+                    base64Image,           // bytes
+                    null,                  // imageUsage
+                    description,           // description
+                    null,                  // resizeWidth
+                    null,                  // resizeHeight
+                    null                   // resizeFileSize
+                );
+            
+            if (response == null || response.getImageId() == null) {
+                throw new Exception("图片上传返回结果为空");
+            }
+            
+            log.info("图片上传成功,imageId: {}, 宽度: {}px, 高度: {}px", 
+                response.getImageId(), response.getImageWidth(), response.getImageHeight());
+            
+            return Long.parseLong(response.getImageId());
+            
+        } finally {
+            // 5. 删除临时文件
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+    
+    /**
+     * 从URL下载品牌形象并上传到腾讯广告
+     * 
+     * @param tencentAds 腾讯广告SDK实例
+     * @param accountId 账户ID
+     * @param brandImageUrl 品牌形象图片URL
+     * @param brandName 品牌名称
+     * @return 品牌图片ID
+     * @throws Exception 上传失败
+     */
+    private Long uploadBrandFromUrl(TencentAds tencentAds, Long accountId, String brandImageUrl, String brandName) throws Exception {
+        File tempFile = null;
+        try {
+            // 1. 从URL下载图片到临时文件
+            tempFile = downloadFileFromUrl(brandImageUrl);
+            
+            // 2. 调用腾讯广告API创建品牌形象
+            BrandAddResponseData response = tencentAds.brand()
+                .brandAdd(
+                    accountId,      // accountId
+                    brandName,      // name
+                    tempFile        // brandImageFile
+                );
+            
+            if (response == null || response.getImageId() == null) {
+                throw new Exception("品牌形象上传返回结果为空");
+            }
+            
+            log.info("品牌形象上传成功,imageId: {}, 品牌名称: {}, 宽度: {}px, 高度: {}px", 
+                response.getImageId(), response.getName(), response.getWidth(), response.getHeight());
+            
+            return Long.parseLong(response.getImageId());
+            
+        } finally {
+            // 3. 删除临时文件
+            if (tempFile != null && tempFile.exists()) {
+                tempFile.delete();
+            }
+        }
+    }
+    
+    /**
+     * 从URL下载文件到临时文件
+     * 
+     * @param fileUrl 文件URL
+     * @return 临时文件
+     * @throws Exception 下载失败
+     */
+    private File downloadFileFromUrl(String fileUrl) throws Exception {
+        HttpURLConnection connection = null;
+        InputStream inputStream = null;
+        FileOutputStream outputStream = null;
+        
+        try {
+            // 创建临时文件
+            String suffix = getFileSuffix(fileUrl);
+            File tempFile = File.createTempFile("gdt_upload_", suffix);
+            
+            // 打开HTTP连接
+            URL url = new URL(fileUrl);
+            connection = (HttpURLConnection) url.openConnection();
+            connection.setConnectTimeout(10000);
+            connection.setReadTimeout(30000);
+            connection.setRequestMethod("GET");
+            connection.connect();
+            
+            int responseCode = connection.getResponseCode();
+            if (responseCode != HttpURLConnection.HTTP_OK) {
+                throw new Exception("下载文件失败,HTTP状态码: " + responseCode);
+            }
+            
+            // 读取文件内容
+            inputStream = connection.getInputStream();
+            outputStream = new FileOutputStream(tempFile);
+            
+            byte[] buffer = new byte[8192];
+            int bytesRead;
+            while ((bytesRead = inputStream.read(buffer)) != -1) {
+                outputStream.write(buffer, 0, bytesRead);
+            }
+            
+            log.info("文件下载成功,URL: {}, 大小: {} bytes", fileUrl, tempFile.length());
+            return tempFile;
+            
+        } finally {
+            if (outputStream != null) {
+                try { outputStream.close(); } catch (Exception e) { }
+            }
+            if (inputStream != null) {
+                try { inputStream.close(); } catch (Exception e) { }
+            }
+            if (connection != null) {
+                connection.disconnect();
+            }
+        }
+    }
+    
+    /**
+     * 获取文件后缀名
+     * 
+     * @param fileUrl 文件URL
+     * @return 文件后缀名(包含点号,如:.jpg)
+     */
+    private String getFileSuffix(String fileUrl) {
+        int lastDotIndex = fileUrl.lastIndexOf('.');
+        int lastSlashIndex = fileUrl.lastIndexOf('/');
+        
+        if (lastDotIndex > lastSlashIndex && lastDotIndex < fileUrl.length() - 1) {
+            return fileUrl.substring(lastDotIndex);
+        }
+        
+        return ".jpg"; // 默认后缀
+    }
+    
+    /**
+     * 计算字节数组的MD5签名
+     * 
+     * @param bytes 字节数组
+     * @return MD5签名(32位小写字符串)
+     * @throws Exception 计算失败
+     */
+    private String calculateMD5(byte[] bytes) throws Exception {
+        MessageDigest md = MessageDigest.getInstance("MD5");
+        md.update(bytes);
+        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;
+    }
 }

+ 173 - 0
src/main/java/com/moka/gdtauto/service/impl/OssFileServiceImpl.java

@@ -0,0 +1,173 @@
+package com.moka.gdtauto.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.moka.gdtauto.entity.OssFile;
+import com.moka.gdtauto.mapper.OssFileMapper;
+import com.moka.gdtauto.service.OssFileService;
+import com.moka.gdtauto.util.OssUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.time.LocalDateTime;
+
+/**
+ * OSS文件Service实现类
+ *
+ * @author moka
+ * @since 2026-02-26
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OssFileServiceImpl extends ServiceImpl<OssFileMapper, OssFile> implements OssFileService {
+
+    private final OssUtil ossUtil;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public OssFile uploadFile(MultipartFile file, String folder, String fileType, Integer businessType, Long userId, String userName) {
+        try {
+            // 验证品牌形象只能上传图片
+            if (businessType != null && businessType == 2 && !"image".equals(fileType)) {
+                throw new RuntimeException("品牌形象只能上传图片文件");
+            }
+            
+            // 上传到OSS
+            String fileUrl = ossUtil.uploadFile(file, folder);
+            
+            // 提取对象Key
+            String objectKey = extractObjectKey(fileUrl);
+            
+            // 获取文件信息
+            String originalFileName = file.getOriginalFilename();
+            String fileExtension = "";
+            if (originalFileName != null && originalFileName.contains(".")) {
+                fileExtension = originalFileName.substring(originalFileName.lastIndexOf(".") + 1);
+            }
+            
+            // 保存文件记录
+            OssFile ossFile = new OssFile();
+            ossFile.setFileName(objectKey);
+            ossFile.setOriginalFileName(originalFileName);
+            ossFile.setFileType(fileType);
+            ossFile.setBusinessType(businessType);
+            ossFile.setFileSize(file.getSize());
+            ossFile.setFileUrl(fileUrl);
+            ossFile.setObjectKey(objectKey);
+            ossFile.setFolder(folder);
+            ossFile.setFileExtension(fileExtension);
+            ossFile.setCreatedBy(userId);
+            ossFile.setCreatedByName(userName);
+            ossFile.setCreatedTime(LocalDateTime.now());
+            ossFile.setUpdatedTime(LocalDateTime.now());
+            
+            save(ossFile);
+            
+            log.info("文件上传成功,ID: {}, URL: {}", ossFile.getId(), fileUrl);
+            return ossFile;
+        } catch (Exception e) {
+            log.error("文件上传失败", e);
+            throw new RuntimeException("文件上传失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean deleteFile(Long fileId) {
+        try {
+            OssFile ossFile = getById(fileId);
+            if (ossFile == null) {
+                log.warn("文件不存在,ID: {}", fileId);
+                return false;
+            }
+            
+            // 删除OSS文件
+            boolean ossDeleted = ossUtil.deleteFile(ossFile.getFileUrl());
+            if (!ossDeleted) {
+                log.warn("OSS文件删除失败,但继续删除数据库记录,ID: {}", fileId);
+            }
+            
+            // 删除数据库记录
+            boolean dbDeleted = removeById(fileId);
+            
+            log.info("文件删除成功,ID: {}, OSS删除: {}, DB删除: {}", fileId, ossDeleted, dbDeleted);
+            return dbDeleted;
+        } catch (Exception e) {
+            log.error("文件删除失败,ID: {}", fileId, e);
+            throw new RuntimeException("文件删除失败: " + e.getMessage());
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean batchDeleteFiles(Long[] fileIds) {
+        boolean allSuccess = true;
+        for (Long fileId : fileIds) {
+            try {
+                boolean success = deleteFile(fileId);
+                if (!success) {
+                    allSuccess = false;
+                }
+            } catch (Exception e) {
+                log.error("批量删除文件失败,ID: {}", fileId, e);
+                allSuccess = false;
+            }
+        }
+        return allSuccess;
+    }
+
+    @Override
+    public Page<OssFile> listFiles(Long page, Long size, String fileType, Integer businessType, String fileName, Long userId) {
+        Page<OssFile> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<OssFile> wrapper = new LambdaQueryWrapper<>();
+        
+        // 文件类型过滤
+        if (fileType != null && !fileType.trim().isEmpty()) {
+            wrapper.eq(OssFile::getFileType, fileType);
+        }
+        
+        // 业务分类过滤
+        if (businessType != null) {
+            wrapper.eq(OssFile::getBusinessType, businessType);
+        }
+        
+        // 文件名模糊查询
+        if (fileName != null && !fileName.trim().isEmpty()) {
+            wrapper.like(OssFile::getOriginalFileName, fileName);
+        }
+        
+        // 创建人过滤
+        if (userId != null) {
+            wrapper.eq(OssFile::getCreatedBy, userId);
+        }
+        
+        // 按创建时间倒序
+        wrapper.orderByDesc(OssFile::getCreatedTime);
+        
+        return page(pageParam, wrapper);
+    }
+
+    /**
+     * 从URL中提取OSS对象Key
+     */
+    private String extractObjectKey(String fileUrl) {
+        if (fileUrl == null || fileUrl.isEmpty()) {
+            return "";
+        }
+        
+        if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
+            int lastSlashIndex = fileUrl.indexOf("/", 8);
+            if (lastSlashIndex != -1 && lastSlashIndex < fileUrl.length() - 1) {
+                return fileUrl.substring(lastSlashIndex + 1);
+            }
+        }
+        
+        return fileUrl;
+    }
+}
+

+ 233 - 0
src/main/java/com/moka/gdtauto/util/OssUtil.java

@@ -0,0 +1,233 @@
+package com.moka.gdtauto.util;
+
+import com.aliyun.oss.OSS;
+import com.aliyun.oss.model.PutObjectRequest;
+import com.moka.gdtauto.config.AliyunOssConfig;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.UUID;
+
+/**
+ * 阿里云OSS工具类
+ * 提供文件上传、删除等功能
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class OssUtil {
+    
+    private final OSS ossClient;
+    private final AliyunOssConfig ossConfig;
+    
+    /**
+     * 上传文件(File对象)
+     * 
+     * @param file 文件对象
+     * @param folder 存储文件夹路径(如:images/、videos/)
+     * @return 文件访问URL(CDN域名)
+     */
+    public String uploadFile(File file, String folder) {
+        String fileName = generateFileName(file.getName(), folder);
+        try {
+            PutObjectRequest putObjectRequest = new PutObjectRequest(
+                    ossConfig.getBucketName(), 
+                    fileName, 
+                    file
+            );
+            ossClient.putObject(putObjectRequest);
+            log.info("文件上传成功:{}", fileName);
+            return getFileUrl(fileName);
+        } catch (Exception e) {
+            log.error("文件上传失败:{}", fileName, e);
+            throw new RuntimeException("文件上传失败", e);
+        }
+    }
+    
+    /**
+     * 上传文件(MultipartFile对象)
+     * 
+     * @param file MultipartFile文件对象
+     * @param folder 存储文件夹路径(如:images/、videos/)
+     * @return 文件访问URL(CDN域名)
+     */
+    public String uploadFile(MultipartFile file, String folder) {
+        String fileName = generateFileName(file.getOriginalFilename(), folder);
+        try (InputStream inputStream = file.getInputStream()) {
+            PutObjectRequest putObjectRequest = new PutObjectRequest(
+                    ossConfig.getBucketName(), 
+                    fileName, 
+                    inputStream
+            );
+            ossClient.putObject(putObjectRequest);
+            log.info("文件上传成功:{}", fileName);
+            return getFileUrl(fileName);
+        } catch (IOException e) {
+            log.error("文件上传失败:{}", fileName, e);
+            throw new RuntimeException("文件上传失败", e);
+        }
+    }
+    
+    /**
+     * 上传文件(InputStream流)
+     * 
+     * @param inputStream 文件输入流
+     * @param originalFileName 原始文件名
+     * @param folder 存储文件夹路径(如:images/、videos/)
+     * @return 文件访问URL(CDN域名)
+     */
+    public String uploadFile(InputStream inputStream, String originalFileName, String folder) {
+        String fileName = generateFileName(originalFileName, folder);
+        try {
+            PutObjectRequest putObjectRequest = new PutObjectRequest(
+                    ossConfig.getBucketName(), 
+                    fileName, 
+                    inputStream
+            );
+            ossClient.putObject(putObjectRequest);
+            log.info("文件上传成功:{}", fileName);
+            return getFileUrl(fileName);
+        } catch (Exception e) {
+            log.error("文件上传失败:{}", fileName, e);
+            throw new RuntimeException("文件上传失败", e);
+        }
+    }
+    
+    /**
+     * 上传字节数组
+     * 
+     * @param bytes 字节数组
+     * @param originalFileName 原始文件名
+     * @param folder 存储文件夹路径(如:images/、videos/)
+     * @return 文件访问URL(CDN域名)
+     */
+    public String uploadFile(byte[] bytes, String originalFileName, String folder) {
+        String fileName = generateFileName(originalFileName, folder);
+        try (ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes)) {
+            PutObjectRequest putObjectRequest = new PutObjectRequest(
+                    ossConfig.getBucketName(), 
+                    fileName, 
+                    inputStream
+            );
+            ossClient.putObject(putObjectRequest);
+            log.info("文件上传成功:{}", fileName);
+            return getFileUrl(fileName);
+        } catch (Exception e) {
+            log.error("文件上传失败:{}", fileName, e);
+            throw new RuntimeException("文件上传失败", e);
+        }
+    }
+    
+    /**
+     * 删除文件
+     * 
+     * @param fileUrl 文件URL(可以是完整URL或OSS对象Key)
+     * @return 是否删除成功
+     */
+    public boolean deleteFile(String fileUrl) {
+        try {
+            String objectKey = extractObjectKey(fileUrl);
+            ossClient.deleteObject(ossConfig.getBucketName(), objectKey);
+            log.info("文件删除成功:{}", objectKey);
+            return true;
+        } catch (Exception e) {
+            log.error("文件删除失败:{}", fileUrl, e);
+            return false;
+        }
+    }
+    
+    /**
+     * 批量删除文件
+     * 
+     * @param fileUrls 文件URL数组
+     * @return 是否全部删除成功
+     */
+    public boolean deleteFiles(String... fileUrls) {
+        boolean allSuccess = true;
+        for (String fileUrl : fileUrls) {
+            if (!deleteFile(fileUrl)) {
+                allSuccess = false;
+            }
+        }
+        return allSuccess;
+    }
+    
+    /**
+     * 判断文件是否存在
+     * 
+     * @param fileUrl 文件URL(可以是完整URL或OSS对象Key)
+     * @return 是否存在
+     */
+    public boolean fileExists(String fileUrl) {
+        try {
+            String objectKey = extractObjectKey(fileUrl);
+            return ossClient.doesObjectExist(ossConfig.getBucketName(), objectKey);
+        } catch (Exception e) {
+            log.error("检查文件是否存在失败:{}", fileUrl, e);
+            return false;
+        }
+    }
+    
+    /**
+     * 生成唯一文件名
+     * 
+     * @param originalFileName 原始文件名
+     * @param folder 文件夹路径
+     * @return 生成的唯一文件名(含路径)
+     */
+    private String generateFileName(String originalFileName, String folder) {
+        String suffix = "";
+        if (originalFileName != null && originalFileName.contains(".")) {
+            suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
+        }
+        String uuid = UUID.randomUUID().toString().replace("-", "");
+        String folderPath = (folder == null || folder.isEmpty()) ? "" : 
+                           (folder.endsWith("/") ? folder : folder + "/");
+        return folderPath + uuid + suffix;
+    }
+    
+    /**
+     * 获取文件访问URL(使用CDN域名)
+     * 
+     * @param objectKey OSS对象Key
+     * @return 完整的文件访问URL
+     */
+    private String getFileUrl(String objectKey) {
+        String cdnDomain = ossConfig.getCdnDomain();
+        if (cdnDomain != null && !cdnDomain.isEmpty()) {
+            return cdnDomain + "/" + objectKey;
+        }
+        // 如果没有配置CDN域名,使用OSS默认域名
+        return "https://" + ossConfig.getBucketName() + "." + 
+               ossConfig.getEndpoint() + "/" + objectKey;
+    }
+    
+    /**
+     * 从URL中提取OSS对象Key
+     * 
+     * @param fileUrl 文件URL
+     * @return OSS对象Key
+     */
+    private String extractObjectKey(String fileUrl) {
+        if (fileUrl == null || fileUrl.isEmpty()) {
+            throw new IllegalArgumentException("文件URL不能为空");
+        }
+        
+        // 如果是完整URL,提取对象Key
+        if (fileUrl.startsWith("http://") || fileUrl.startsWith("https://")) {
+            int lastSlashIndex = fileUrl.indexOf("/", 8); // 跳过协议部分
+            if (lastSlashIndex != -1 && lastSlashIndex < fileUrl.length() - 1) {
+                return fileUrl.substring(lastSlashIndex + 1);
+            }
+        }
+        
+        // 否则认为就是对象Key
+        return fileUrl;
+    }
+}

+ 9 - 0
src/main/resources/application-local.yml

@@ -47,3 +47,12 @@ spring:
           min-idle: 0
           max-wait: -1ms
       timeout: 3000ms
+
+# 阿里云OSS配置(本地环境)
+aliyun:
+  oss:
+    access-key-id: LTAI5tHxyfkjLtupaxPSZHPU
+    access-key-secret: xPQBp5WnH2Ei57xjpeRvbVKnKOrgH7
+    bucket-name: ad-auto
+    endpoint: oss-cn-shanghai.aliyuncs.com
+    cdn-domain: https://ad-autoss.luzie.cn

+ 9 - 0
src/main/resources/application-pro.yml

@@ -34,3 +34,12 @@ spring:
           min-idle: 0
           max-wait: -1ms
       timeout: 3000ms
+
+# 阿里云OSS配置(生产环境-使用内网endpoint)
+aliyun:
+  oss:
+    access-key-id: LTAI5tHxyfkjLtupaxPSZHPU
+    access-key-secret: xPQBp5WnH2Ei57xjpeRvbVKnKOrgH7
+    bucket-name: ad-auto
+    endpoint: oss-cn-shanghai-internal.aliyuncs.com
+    cdn-domain: https://ad-autoss.luzie.cn

+ 9 - 0
src/main/resources/application-test.yml

@@ -34,3 +34,12 @@ spring:
           max-wait: -1ms
       timeout: 3000ms
 
+# 阿里云OSS配置(测试环境)
+aliyun:
+  oss:
+    access-key-id: LTAI5tHxyfkjLtupaxPSZHPU
+    access-key-secret: xPQBp5WnH2Ei57xjpeRvbVKnKOrgH7
+    bucket-name: ad-auto
+    endpoint: oss-cn-shanghai.aliyuncs.com
+    cdn-domain: https://ad-autoss.luzie.cn
+

+ 7 - 1
src/main/resources/application.yml

@@ -54,4 +54,10 @@ tencent:
     oauth-redirect-uri: https://moka-auto.mokamrp.com/api/gdt/auth/callback
     oauth-ok-redirect-uri: https://moka-auto.mokamrp.com/api/gdt/auth/ok
     client-id: 1112036271
-    secret: CPemSOpMcp8KO1Ie
+    secret: CPemSOpMcp8KO1Ie
+
+# 阿里云OSS配置
+aliyun:
+  oss:
+    bucket-name: ad-auto
+    cdn-domain: https://ad-autoss.luzie.cn

+ 22 - 0
src/main/resources/sql/oss_file.sql

@@ -0,0 +1,22 @@
+-- OSS文件管理表
+CREATE TABLE `oss_file` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
+  `file_name` varchar(255) NOT NULL COMMENT '文件名',
+  `original_file_name` varchar(255) DEFAULT NULL COMMENT '原始文件名',
+  `file_type` varchar(50) DEFAULT NULL COMMENT '文件类型(image/video/document等)',
+  `business_type` int(11) DEFAULT NULL COMMENT '业务分类(1:创意 2:品牌形象)',
+  `file_size` bigint(20) DEFAULT NULL COMMENT '文件大小(字节)',
+  `file_url` varchar(500) NOT NULL COMMENT '文件URL',
+  `object_key` varchar(255) NOT NULL COMMENT 'OSS对象Key',
+  `folder` varchar(100) DEFAULT NULL COMMENT '存储文件夹路径',
+  `file_extension` varchar(20) DEFAULT NULL COMMENT '文件扩展名',
+  `created_by` bigint(20) DEFAULT NULL COMMENT '创建人ID',
+  `created_by_name` varchar(100) 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_file_type` (`file_type`),
+  KEY `idx_business_type` (`business_type`),
+  KEY `idx_created_by` (`created_by`),
+  KEY `idx_created_time` (`created_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OSS文件管理表';

+ 4 - 0
src/main/resources/sql/oss_file_add_business_type.sql

@@ -0,0 +1,4 @@
+-- 为已存在的oss_file表添加business_type字段
+ALTER TABLE `oss_file` 
+ADD COLUMN `business_type` int(11) DEFAULT NULL COMMENT '业务分类(1:创意 2:品牌形象)' AFTER `file_type`,
+ADD KEY `idx_business_type` (`business_type`);

+ 3 - 0
src/test/java/com/moka/gdtauto/service/AdPlanConfAsyncTest.java

@@ -76,6 +76,9 @@ class AdPlanConfAsyncTest {
 
         System.out.println("📊 找到 " + plans.size() + " 个广告计划:");
         for (AdPlan plan : plans) {
+            if (plan.getId() > 1) {
+                continue;
+            }
             System.out.println("  - 计划ID: " + plan.getId() + 
                 ", 广告名称: " + plan.getAdgroupName() + 
                 ", 账户ID: " + plan.getAccountId() +

+ 179 - 0
src/test/java/com/moka/gdtauto/service/TencentBrandAddTest.java

@@ -0,0 +1,179 @@
+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.BrandAddResponseData;
+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;
+
+/**
+ * 腾讯广告品牌形象创建测试
+ * https://developers.e.qq.com/v3.0/docs/api/brand/add
+ * @author moka
+ * @since 2026-02-26
+ * mvn test -Dtest=TencentBrandAddTest#testAddBrand -Dspring.profiles.active=test
+ */
+@SpringBootTest(classes = GdtAutoApplication.class)
+@ActiveProfiles("test")
+class TencentBrandAddTest {
+
+    @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_JOB_NUMBER = "12458";
+
+    @Test
+    @DisplayName("测试创建品牌形象")
+    void testAddBrand() {
+        System.out.println("\n========================================");
+        System.out.println("开始创建品牌形象");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(TEST_JOB_NUMBER, ORG_ACCOUNT_ID);
+            tencentAds.setDebug(true);
+
+            // 品牌形象图片文件路径(请替换为实际图片路径)
+            // 注意:图片尺寸必须为 512x512,大小不超过 400KB,格式:*.jpg、*.jpeg、*.png
+            String imagePath = "/Users/pudongliang/moka_workspace/gdt-auto/brand.jpg";
+            File imageFile = new File(imagePath);
+            
+            if (!imageFile.exists()) {
+                System.err.println("❌ 品牌形象图片文件不存在: " + imagePath);
+                System.err.println("请修改 imagePath 为实际的图片文件路径");
+                System.err.println("⚠️  注意:图片尺寸必须为 512x512,大小不超过 400KB");
+                return;
+            }
+
+            String brandName = "情感美文";
+            
+            // 输出请求参数
+            System.out.println("\n========== 请求参数 ==========");
+            System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+            System.out.println("品牌名称: " + brandName);
+            System.out.println("图片路径: " + imagePath);
+            System.out.println("========================================\n");
+
+            // 调用创建品牌形象接口
+            System.out.println("正在调用腾讯广告创建品牌形象API...");
+            BrandAddResponseData response = tencentAds.brand()
+                .brandAdd(
+                    TEST_ACCOUNT_ID,      // accountId
+                    brandName,            // name
+                    imageFile             // brandImageFile
+                );
+
+            // 输出响应结果
+            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.getAccountId());
+                System.out.println("📊 品牌名称: " + response.getName());
+                System.out.println("📊 图片ID: " + response.getImageId());
+                System.out.println("📊 图片宽度: " + response.getWidth() + "px");
+                System.out.println("📊 图片高度: " + response.getHeight() + "px");
+                System.out.println("📊 图片URL: " + response.getImageUrl());
+                System.out.println("📊 创建时间: " + response.getCreatedTime());
+            } 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 testAddBrandWithOrgId() {
+        System.out.println("\n========================================");
+        System.out.println("开始创建品牌形象(指定组织ID)");
+        System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+        System.out.println("组织ID: " + ORG_ACCOUNT_ID);
+        System.out.println("========================================\n");
+
+        try {
+            // 获取TencentAds实例
+            TencentAds tencentAds = clientFactory.getTencentAds(TEST_JOB_NUMBER, ORG_ACCOUNT_ID);
+            tencentAds.setDebug(true);
+
+            // 品牌形象图片文件路径(请替换为实际图片路径)
+            // 注意:图片尺寸必须为 512x512,大小不超过 400KB,格式:*.jpg、*.jpeg、*.png
+            String imagePath = "/Users/pudongliang/moka_workspace/gdt-auto/brand.jpg";
+            File imageFile = new File(imagePath);
+            
+            if (!imageFile.exists()) {
+                System.err.println("❌ 品牌形象图片文件不存在: " + imagePath);
+                System.err.println("请修改 imagePath 为实际的图片文件路径");
+                System.err.println("⚠️  注意:图片尺寸必须为 512x512,大小不超过 400KB");
+                return;
+            }
+
+            String brandName = "情感美文";
+            
+            // 输出请求参数
+            System.out.println("\n========== 请求参数 ==========");
+            System.out.println("账户ID: " + TEST_ACCOUNT_ID);
+            System.out.println("组织ID: " + ORG_ACCOUNT_ID);
+            System.out.println("品牌名称: " + brandName);
+            System.out.println("图片路径: " + imagePath);
+            System.out.println("========================================\n");
+
+            // 调用创建品牌形象接口
+            System.out.println("正在调用腾讯广告创建品牌形象API...");
+            BrandAddResponseData response = tencentAds.brand()
+                .brandAdd(
+                    TEST_ACCOUNT_ID,      // accountId
+                    brandName,            // name
+                    imageFile             // brandImageFile
+                );
+
+            // 输出响应结果
+            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.getAccountId());
+                System.out.println("📊 品牌名称: " + response.getName());
+                System.out.println("📊 图片ID: " + response.getImageId());
+                System.out.println("📊 图片宽度: " + response.getWidth() + "px");
+                System.out.println("📊 图片高度: " + response.getHeight() + "px");
+                System.out.println("📊 图片URL: " + response.getImageUrl());
+                System.out.println("📊 创建时间: " + response.getCreatedTime());
+            } else {
+                System.out.println("⚠️  返回结果为空");
+            }
+
+        } catch (Exception e) {
+            System.err.println("\n❌ API调用失败");
+            System.err.println("错误信息: " + e.getMessage());
+            e.printStackTrace();
+            throw new RuntimeException("测试失败", e);
+        }
+    }
+}

+ 30 - 0
src/test/java/com/moka/gdtauto/util/OssUtil.java

@@ -0,0 +1,30 @@
+package com.moka.gdtauto.util;
+
+import org.springframework.stereotype.Component;
+
+@Component
+public class OssUtil {
+    
+}
+
+/**
+ * ---------ad-auto---------
+
+Bucket 域名:ad-auto
+
+
+
+AccessKey ID xxx
+AccessKey Secret xxx
+
+
+
+Endpoint(地域节点):
+
+本地公网:oss-cn-shanghai.aliyuncs.com
+
+上线私网:oss-cn-shanghai-internal.aliyuncs.com
+
+
+cdn域名:https://ad-autoss.luzie.cn
+ */