Przeglądaj źródła

mp:后端增加上传永久素材的接口

YunaiV 2 lat temu
rodzic
commit
431569fd09

+ 5 - 2
yudao-module-mp/yudao-module-mp-api/src/main/java/cn/iocoder/yudao/module/mp/enums/ErrorCodeConstants.java

@@ -27,11 +27,14 @@ public interface ErrorCodeConstants {
     ErrorCode TAG_DELETE_FAIL = new ErrorCode(1006002001, "删除标签失败,原因:{}");
     ErrorCode TAG_GET_FAIL = new ErrorCode(1006002001, "获得标签失败,原因:{}");
 
-    // ========== 公众号标签 1006003000============
+    // ========== 公众号粉丝 1006003000============
     ErrorCode USER_NOT_EXISTS = new ErrorCode(1006003000, "用户不存在");
     ErrorCode USER_UPDATE_TAG_FAIL = new ErrorCode(1006003001, "更新用户标签失败,原因:{}");
 
+    // ========== 公众号素材 1006004000============
+    ErrorCode MATERIAL_UPLOAD_FAIL = new ErrorCode(1006004000, "上传素材失败,原因:{}");
+
     // TODO 要处理下
-    ErrorCode COMMON_NOT_EXISTS = new ErrorCode(1006001002, "用户不存在");
+    ErrorCode MENU_NOT_EXISTS = new ErrorCode(1006001002, "菜单不存在");
 
 }

+ 9 - 0
yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/material/MpMaterialController.java

@@ -1,6 +1,7 @@
 package cn.iocoder.yudao.module.mp.controller.admin.material;
 
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadPermanentReqVO;
 import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadRespVO;
 import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadTemporaryReqVO;
 import cn.iocoder.yudao.module.mp.convert.material.MpMaterialConvert;
@@ -36,4 +37,12 @@ public class MpMaterialController {
         return success(MpMaterialConvert.INSTANCE.convert(material));
     }
 
+    @ApiOperation("上传永久素材")
+    @PostMapping("/upload-permanent")
+    public CommonResult<MpMaterialUploadRespVO> uploadPermanentMaterial(
+            @Valid MpMaterialUploadPermanentReqVO reqVO) throws IOException {
+        MpMaterialDO material = mpMaterialService.uploadPermanentMaterial(reqVO);
+        return success(MpMaterialConvert.INSTANCE.convert(material));
+    }
+
 }

+ 54 - 0
yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/controller/admin/material/vo/MpMaterialUploadPermanentReqVO.java

@@ -0,0 +1,54 @@
+package cn.iocoder.yudao.module.mp.controller.admin.material.vo;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+import me.chanjar.weixin.common.api.WxConsts;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.validation.constraints.AssertTrue;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.NotNull;
+
+@ApiModel("管理后台 - 公众号素材上传永久 Request VO")
+@Data
+public class MpMaterialUploadPermanentReqVO {
+
+    @ApiModelProperty(value = "公众号账号的编号", required = true, example = "2048")
+    @NotNull(message = "公众号账号的编号不能为空")
+    private Long accountId;
+
+    @ApiModelProperty(value = "文件类型", required = true, example = "image", notes = "参见 WxConsts.MediaFileType 枚举")
+    @NotEmpty(message = "文件类型不能为空")
+    private String type;
+
+    @ApiModelProperty(value = "文件附件", required = true)
+    @NotNull(message = "文件不能为空")
+    @JsonIgnore // 避免被操作日志,进行序列化,导致报错
+    private MultipartFile file;
+
+    @ApiModelProperty(value = "名字", example = "wechat.mp", notes = "如果 name 为空,则使用 file 文件名")
+    private String name;
+
+    @ApiModelProperty(value = "视频素材的标题", example = "视频素材的标题", notes = "文件类型为 video 时,必填")
+    private String title;
+    @ApiModelProperty(value = "视频素材的描述", example = "视频素材的描述", notes = "文件类型为 video 时,必填")
+    private String introduction;
+
+    @AssertTrue(message = "标题不能为空")
+    public boolean isTitleValid() {
+        // 生成场景为管理后台时,必须设置上级菜单,不然生成的菜单 SQL 是无父级菜单的
+        return ObjectUtil.notEqual(type, WxConsts.MediaFileType.VIDEO)
+                || title != null;
+    }
+
+    @AssertTrue(message = "描述不能为空")
+    public boolean isIntroductionValid() {
+        // 生成场景为管理后台时,必须设置上级菜单,不然生成的菜单 SQL 是无父级菜单的
+        return ObjectUtil.notEqual(type, WxConsts.MediaFileType.VIDEO)
+                || introduction != null;
+    }
+
+}

+ 16 - 0
yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/convert/material/MpMaterialConvert.java

@@ -3,11 +3,14 @@ package cn.iocoder.yudao.module.mp.convert.material;
 import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadRespVO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.material.MpMaterialDO;
+import me.chanjar.weixin.mp.bean.material.WxMpMaterial;
 import org.mapstruct.Mapper;
 import org.mapstruct.Mapping;
 import org.mapstruct.Mappings;
 import org.mapstruct.factory.Mappers;
 
+import java.io.File;
+
 @Mapper
 public interface MpMaterialConvert {
 
@@ -20,6 +23,19 @@ public interface MpMaterialConvert {
     })
     MpMaterialDO convert(String mediaId, String type, String url, MpAccountDO account);
 
+    @Mappings({
+            @Mapping(target = "id", ignore = true),
+            @Mapping(source = "account.id", target = "accountId"),
+            @Mapping(source = "account.appId", target = "appId"),
+            @Mapping(source = "name", target = "name")
+    })
+    MpMaterialDO convert(String mediaId, String type, String url, MpAccountDO account,
+                         String name, String title, String introduction, String mpUrl);
+
     MpMaterialUploadRespVO convert(MpMaterialDO bean);
 
+    default WxMpMaterial convert(String name, File file, String title, String introduction) {
+        return new WxMpMaterial(name, file, title, introduction);
+    }
+
 }

+ 18 - 0
yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/material/MpMaterialService.java

@@ -1,5 +1,6 @@
 package cn.iocoder.yudao.module.mp.service.material;
 
+import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadPermanentReqVO;
 import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadTemporaryReqVO;
 import cn.iocoder.yudao.module.mp.dal.dataobject.material.MpMaterialDO;
 import me.chanjar.weixin.common.api.WxConsts;
@@ -26,5 +27,22 @@ public interface MpMaterialService {
      */
     String downloadMaterialUrl(Long accountId, String mediaId, String type);
 
+    /**
+     * 上传临时素材
+     *
+     * @param reqVO 请求
+     * @return 素材
+     * @throws IOException 文件操作发生异常
+     */
     MpMaterialDO uploadTemporaryMaterial(@Valid MpMaterialUploadTemporaryReqVO reqVO) throws IOException;
+
+    /**
+     * 上传永久素材
+     *
+     * @param reqVO 请求
+     * @return 素材
+     * @throws IOException 文件操作发生异常
+     */
+    MpMaterialDO uploadPermanentMaterial(@Valid MpMaterialUploadPermanentReqVO reqVO) throws IOException;
+
 }

+ 40 - 2
yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/material/MpMaterialServiceImpl.java

@@ -3,7 +3,9 @@ package cn.iocoder.yudao.module.mp.service.material;
 import cn.hutool.core.io.FileTypeUtil;
 import cn.hutool.core.io.FileUtil;
 import cn.hutool.core.util.ObjUtil;
+import cn.hutool.core.util.StrUtil;
 import cn.iocoder.yudao.module.infra.api.file.FileApi;
+import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadPermanentReqVO;
 import cn.iocoder.yudao.module.mp.controller.admin.material.vo.MpMaterialUploadTemporaryReqVO;
 import cn.iocoder.yudao.module.mp.convert.material.MpMaterialConvert;
 import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO;
@@ -15,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
 import me.chanjar.weixin.common.bean.result.WxMediaUploadResult;
 import me.chanjar.weixin.common.error.WxErrorException;
 import me.chanjar.weixin.mp.api.WxMpService;
+import me.chanjar.weixin.mp.bean.material.WxMpMaterialUploadResult;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import org.springframework.validation.annotation.Validated;
@@ -23,6 +26,9 @@ import javax.annotation.Resource;
 import java.io.File;
 import java.io.IOException;
 
+import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
+import static cn.iocoder.yudao.module.mp.enums.ErrorCodeConstants.MATERIAL_UPLOAD_FAIL;
+
 /**
  * 公众号素材 Service 接口
  *
@@ -87,8 +93,7 @@ public class MpMaterialServiceImpl implements MpMaterialService {
             mediaId = ObjUtil.defaultIfNull(result.getMediaId(), result.getThumbMediaId());
             url = uploadFile(mediaId, file);
         } catch (WxErrorException e) {
-            // TODO yunai:待完善
-            throw new RuntimeException(e);
+            throw exception(MATERIAL_UPLOAD_FAIL, e.getError().getErrorMsg());
         } finally {
             FileUtil.del(file);
         }
@@ -101,6 +106,39 @@ public class MpMaterialServiceImpl implements MpMaterialService {
         return material;
     }
 
+    @Override
+    public MpMaterialDO uploadPermanentMaterial(MpMaterialUploadPermanentReqVO reqVO) throws IOException {
+        WxMpService mpService = mpServiceFactory.getRequiredMpService(reqVO.getAccountId());
+        // 第一步,上传到公众号
+        String name = StrUtil.blankToDefault(reqVO.getName(), reqVO.getFile().getName());
+        File file = null;
+        WxMpMaterialUploadResult result;
+        String mediaId;
+        String url;
+        try {
+            // 写入到临时文件
+            file = FileUtil.newFile(FileUtil.getTmpDirPath() + reqVO.getFile().getOriginalFilename());
+            reqVO.getFile().transferTo(file);
+            // 上传到公众号
+            result = mpService.getMaterialService().materialFileUpload(reqVO.getType(),
+                    MpMaterialConvert.INSTANCE.convert(name, file, reqVO.getTitle(), reqVO.getIntroduction()));
+            // 上传到文件服务
+            mediaId = ObjUtil.defaultIfNull(result.getMediaId(), result.getMediaId());
+            url = uploadFile(mediaId, file);
+        } catch (WxErrorException e) {
+            throw exception(MATERIAL_UPLOAD_FAIL, e.getError().getErrorMsg());
+        } finally {
+            FileUtil.del(file);
+        }
+
+        // 第二步,存储到数据库
+        MpAccountDO account = mpAccountService.getRequiredAccount(reqVO.getAccountId());
+        MpMaterialDO material = MpMaterialConvert.INSTANCE.convert(mediaId, reqVO.getType(), url, account,
+                        name, reqVO.getTitle(), reqVO.getIntroduction(), result.getUrl()).setPermanent(true);
+        mpMaterialMapper.insert(material);
+        return material;
+    }
+
     /**
      * 下载微信媒体文件的内容,并上传到文件服务
      *

+ 1 - 1
yudao-module-mp/yudao-module-mp-biz/src/main/java/cn/iocoder/yudao/module/mp/service/menu/MpMenuServiceImpl.java

@@ -77,7 +77,7 @@ public class MpMenuServiceImpl implements MpMenuService {
     private void validateMenuExists(Long id) {
         if (mpMenuMapper.selectById(id) == null) {
             // TODO 芋艿:错误码不太对
-            throw exception(COMMON_NOT_EXISTS);
+            throw exception(MENU_NOT_EXISTS);
         }
     }
 

+ 255 - 0
yudao-ui-admin/src/views/mp/components/wx-material-select/main.vue

@@ -0,0 +1,255 @@
+<!--
+  - Copyright (C) 2018-2019
+  - All rights reserved, Designed By www.joolun.com
+-->
+<template>
+  <!-- 类型:图片 -->
+  <div v-if="objData.type === 'image'">
+    <div class="waterfall" v-loading="tableLoading">
+      <div class="waterfall-item" v-for="item in tableData" :key="item.mediaId">
+        <img class="material-img" :src="item.url">
+        <p class="item-name">{{item.name}}</p>
+        <el-row class="ope-row">
+          <el-button size="mini" type="success" @click="selectMaterial(item)">选择
+            <i class="el-icon-circle-check el-icon--right"></i>
+          </el-button>
+        </el-row>
+      </div>
+    </div>
+    <div v-if="tableData.length <= 0 && !tableLoading" class="el-table__empty-block">
+      <span class="el-table__empty-text">暂无数据</span>
+    </div>
+    <span slot="footer" class="dialog-footer">
+      <el-pagination @size-change="sizeChange" @current-change="currentChange" :current-page.sync="page.currentPage"
+                     :page-sizes="[10, 20]" :page-size="page.pageSize" layout="total, sizes, prev, pager, next, jumper"
+                     :total="page.total" class="pagination" />
+    </span>
+  </div>
+  <div v-else-if="objData.repType == 'voice'">
+    <!-- TODO 芋艿:需要翻译 -->
+<!--    <avue-crud ref="crud"-->
+<!--               :page="page"-->
+<!--               :data="tableData"-->
+<!--               :table-loading="tableLoading"-->
+<!--               :option="tableOptionVoice"-->
+<!--               @on-load="getPage"-->
+<!--               @size-change="sizeChange"-->
+<!--               @refresh-change="refreshChange">-->
+<!--      <template slot-scope="scope"-->
+<!--                slot="menu">-->
+<!--        <el-button type="text"-->
+<!--                   icon="el-icon-circle-plus"-->
+<!--                   size="small"-->
+<!--                   plain-->
+<!--                   @click="selectMaterial(scope.row)">选择</el-button>-->
+<!--      </template>-->
+<!--    </avue-crud>-->
+  </div>
+  <div v-else-if="objData.repType == 'video'">
+    <!-- TODO 芋艿:需要翻译 -->
+    <!--    <avue-crud ref="crud"-->
+<!--               :page="page"-->
+<!--               :data="tableData"-->
+<!--               :table-loading="tableLoading"-->
+<!--               :option="tableOptionVideo"-->
+<!--               @on-load="getPage"-->
+<!--               @size-change="sizeChange"-->
+<!--               @refresh-change="refreshChange">-->
+<!--      <template slot-scope="scope"-->
+<!--                slot="menu">-->
+<!--        <el-button type="text"-->
+<!--                   icon="el-icon-circle-plus"-->
+<!--                   size="small"-->
+<!--                   plain-->
+<!--                   @click="selectMaterial(scope.row)">选择</el-button>-->
+<!--      </template>-->
+<!--    </avue-crud>-->
+  </div>
+  <div v-else-if="objData.repType == 'news'">
+    <div class="waterfall" v-loading="tableLoading">
+      <div class="waterfall-item" v-for="item in tableData" :key="item.mediaId" v-if="item.content && item.content.articles">
+        <WxNews :objData="item.content.articles"></WxNews>
+        <el-row class="ope-row">
+          <el-button size="mini" type="success" @click="selectMaterial(item)">选择<i class="el-icon-circle-check el-icon--right"></i></el-button>
+        </el-row>
+      </div>
+    </div>
+    <div v-if="tableData.length <=0 && !tableLoading" class="el-table__empty-block">
+      <span class="el-table__empty-text">暂无数据</span>
+    </div>
+    <span slot="footer" class="dialog-footer">
+      <el-pagination
+        @size-change="sizeChange"
+        :current-page.sync="page.currentPage"
+        :page-sizes="[10, 20]"
+        :page-size="page.pageSize"
+        layout="total, sizes, prev, pager, next, jumper"
+        :total="page.total"
+        class="pagination"
+      >
+      </el-pagination>
+    </span>
+  </div>
+</template>
+
+<script>
+  import { getPage, getMaterialVideo } from '@/api/wxmp/wxmaterial'
+  // import { tableOptionVoice } from '@/const/crud/wxmp/wxmaterial_voice'
+  // import { tableOptionVideo } from '@/const/crud/wxmp/wxmaterial_video'
+  import WxNews from '@/views/mp/components/wx-news/main.vue';
+  import {getPage as getPageNews} from '@/api/wxmp/wxfreepublish'
+  import {getPage as getPageNewsDraft} from '@/api/wxmp/wxdraft'
+
+  export default {
+    name: "wxMaterialSelect",
+    components: {
+      WxNews
+    },
+    props: {
+      objData: {
+        type: Object,
+        required: true
+      },
+      //图文类型:1、已发布图文;2、草稿箱图文
+      newsType:{
+        type: String,
+        default: "1"
+      },
+    },
+    data() {
+      return {
+        tableLoading: false,
+        tableData: [],
+        page: {
+          total: 0, // 总页数
+          currentPage: 1, // 当前页数
+          pageSize: 20, // 每页显示多少条
+          ascs:[],//升序字段
+          descs:[]//降序字段
+        },
+        // tableOptionVoice: tableOptionVoice,
+        // tableOptionVideo: tableOptionVideo,
+      }
+    },
+    created() {
+      this.getPage(this.page)
+    },
+    methods:{
+      selectMaterial(item){
+        this.$emit('selectMaterial', item)
+      },
+      getPage(page, params) {
+        this.tableLoading = true
+        if(this.objData.repType == 'news'){
+          if(this.newsType == '1'){
+            getPageNews(Object.assign({
+              current: page.currentPage,
+              size: page.pageSize,
+              appId:this.appId,
+            }, params)).then(response => {
+              let tableData = response.data.items
+              tableData.forEach(item => {
+                item.mediaId = item.articleId
+                item.content.articles = item.content.newsItem
+              })
+              this.tableData = tableData
+              this.page.total = response.data.totalCount
+              this.page.currentPage = page.currentPage
+              this.page.pageSize = page.pageSize
+              this.tableLoading = false
+            })
+          }else if(this.newsType == '2'){
+            getPageNewsDraft(Object.assign({
+              current: page.currentPage,
+              size: page.pageSize,
+              appId:this.appId,
+            }, params)).then(response => {
+              let tableData = response.data.items
+              tableData.forEach(item => {
+                item.mediaId = item.mediaId
+                item.content.articles = item.content.newsItem
+              })
+              this.tableData = tableData
+              this.page.total = response.data.totalCount
+              this.page.currentPage = page.currentPage
+              this.page.pageSize = page.pageSize
+              this.tableLoading = false
+            })
+          }
+        }else{
+          getPage(Object.assign({
+            current: page.currentPage,
+            size: page.pageSize,
+            appId:this.appId,
+            type:this.objData.repType
+          }, params)).then(response => {
+            this.tableData = response.data.items
+            this.page.total = response.data.totalCount
+            this.page.currentPage = page.currentPage
+            this.page.pageSize = page.pageSize
+            this.tableLoading = false
+          })
+        }
+      },
+      sizeChange(val) {
+        this.page.currentPage = 1
+        this.page.pageSize = val
+        this.getPage(this.page)
+      },
+      currentChange(val) {
+        this.page.currentPage = val
+        this.getPage(this.page)
+      },
+      /**
+       * 刷新回调
+       */
+      refreshChange(page) {
+        this.getPage(this.page)
+      }
+    }
+  };
+</script>
+
+<style lang="scss" scoped>
+  /*瀑布流样式*/
+  .waterfall {
+    width: 100%;
+    column-gap:10px;
+    column-count: 5;
+    margin: 0 auto;
+  }
+  .waterfall-item {
+    padding: 10px;
+    margin-bottom: 10px;
+    break-inside: avoid;
+    border: 1px solid #eaeaea;
+  }
+  .material-img {
+    width: 100%;
+  }
+  p {
+    line-height: 30px;
+  }
+  @media (min-width: 992px) and (max-width: 1300px) {
+    .waterfall {
+      column-count: 3;
+    }
+    p {
+      color:red;
+    }
+  }
+  @media (min-width: 768px) and (max-width: 991px) {
+    .waterfall {
+      column-count: 2;
+    }
+    p {
+      color: orange;
+    }
+  }
+  @media (max-width: 767px) {
+    .waterfall {
+      column-count: 1;
+    }
+  }
+  /*瀑布流样式*/
+</style>