mp:增加上传临时素材的接口

This commit is contained in:
YunaiV 2023-01-11 20:13:20 +08:00
parent 541ee81300
commit ec872c702c
14 changed files with 426 additions and 66 deletions

View File

@ -4,9 +4,6 @@ import lombok.*;
import io.swagger.annotations.*;
import javax.validation.constraints.*;
/**
* @author fengdan
*/
@ApiModel("管理后台 - 公众号账号更新 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)

View File

@ -0,0 +1,39 @@
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.MpMaterialUploadRespVO;
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.material.MpMaterialDO;
import cn.iocoder.yudao.module.mp.service.material.MpMaterialService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.io.IOException;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Api(tags = "管理后台 - 公众号素材")
@RestController
@RequestMapping("/mp/material")
@Validated
public class MpMaterialController {
@Resource
private MpMaterialService mpMaterialService;
@ApiOperation("上传临时素材")
@PostMapping("/upload-temporary")
public CommonResult<MpMaterialUploadRespVO> uploadTemporaryMaterial(
@Valid MpMaterialUploadTemporaryReqVO reqVO) throws IOException {
MpMaterialDO material = mpMaterialService.uploadTemporaryMaterial(reqVO);
return success(MpMaterialConvert.INSTANCE.convert(material));
}
}

View File

@ -0,0 +1,17 @@
package cn.iocoder.yudao.module.mp.controller.admin.material.vo;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
@ApiModel("管理后台 - 公众号素材上传结果 Response VO")
@Data
public class MpMaterialUploadRespVO {
@ApiModelProperty(value = "素材的 media_id", required = true, example = "123")
private String mediaId;
@ApiModelProperty(value = "素材的 URL", required = true, example = "https://www.iocoder.cn/1.png")
private String url;
}

View File

@ -0,0 +1,29 @@
package cn.iocoder.yudao.module.mp.controller.admin.material.vo;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
@ApiModel("管理后台 - 公众号素材上传临时 Request VO")
@Data
public class MpMaterialUploadTemporaryReqVO {
@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;
}

View File

@ -0,0 +1,25 @@
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 org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;
import org.mapstruct.factory.Mappers;
@Mapper
public interface MpMaterialConvert {
MpMaterialConvert INSTANCE = Mappers.getMapper(MpMaterialConvert.class);
@Mappings({
@Mapping(target = "id", ignore = true),
@Mapping(source = "account.id", target = "accountId"),
@Mapping(source = "account.appId", target = "appId"),
})
MpMaterialDO convert(String mediaId, String type, String url, MpAccountDO account);
MpMaterialUploadRespVO convert(MpMaterialDO bean);
}

View File

@ -0,0 +1,95 @@
package cn.iocoder.yudao.module.mp.dal.dataobject.material;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.mp.dal.dataobject.account.MpAccountDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import me.chanjar.weixin.common.api.WxConsts;
/**
* 公众号素材 DO
*
* 1. <a href="https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/New_temporary_materials.html">临时素材</a>
* 2. <a href="https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Adding_Permanent_Assets.html">永久素材</a>
*
* @author 芋道源码
*/
@TableName("mp_material")
@KeySequence("mp_material_seq") // 用于 OraclePostgreSQLKingbaseDB2H2 数据库的主键自增如果是 MySQL 等数据库可不写
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MpMaterialDO extends BaseDO {
/**
* 主键
*/
@TableId
private Long id;
/**
* 微信公众号 ID
*
* 关联 {@link MpAccountDO#getId()}
*/
private Long accountId;
/**
* 微信公众号 appid
*
* 冗余 {@link MpAccountDO#getAppId()}
*/
private String appId;
/**
* 公众号素材 id
*/
private String mediaId;
/**
* 文件类型
*
* 枚举 {@link WxConsts.MediaFileType}
*/
private String type;
/**
* 是否永久
*
* true - 永久素材
* false - 临时素材
*/
private Boolean permanent;
/**
* 文件服务器的 URL
*/
private String url;
/**
* 名字
*
* 只有永久素材使用
*/
private String name;
/**
* 公众号文件 URL
*
* 只有永久素材使用
*/
private String mpUrl;
/**
* 视频素材的标题
*
* 只有永久素材使用
*/
private String title;
/**
* 视频素材的描述
*
* 只有永久素材使用
*/
private String introduction;
}

View File

@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.mp.dal.mysql.material;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.mp.dal.dataobject.material.MpMaterialDO;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MpMaterialMapper extends BaseMapperX<MpMaterialDO> {
default MpMaterialDO selectByAccountIdAndMediaId(Long accountId, String mediaId) {
return selectOne(MpMaterialDO::getAccountId, accountId,
MpMaterialDO::getMediaId, mediaId);
}
}

View File

@ -80,4 +80,25 @@ public class MpUtils {
ValidationUtils.validate(validator, message, group);
}
/**
* 根据消息类型获得对应的媒体文件类型
*
* 注意不会返回 WxConsts.MediaFileType.THUMB因为该类型会有明确标注
*
* @param messageType 消息类型 {@link WxConsts.XmlMsgType}
* @return 媒体文件类型 {@link WxConsts.MediaFileType}
*/
public static String getMediaFileType(String messageType) {
switch (messageType) {
case WxConsts.XmlMsgType.IMAGE:
return WxConsts.MediaFileType.IMAGE;
case WxConsts.XmlMsgType.VOICE:
return WxConsts.MediaFileType.VOICE;
case WxConsts.XmlMsgType.VIDEO:
return WxConsts.MediaFileType.VIDEO;
default:
return WxConsts.MediaFileType.FILE;
}
}
}

View File

@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.mp.service.material;
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;
import javax.validation.Valid;
import java.io.IOException;
/**
* 公众号素材 Service 接口
*
* @author 芋道源码
*/
public interface MpMaterialService {
/**
* 获得素材的 URL
*
* URL 来自我们自己的文件服务器存储的 URL不是公众号存储的 URL
*
* @param accountId 公众号账号编号
* @param mediaId 公众号素材 id
* @param type 文件类型 {@link WxConsts.MediaFileType}
* @return 素材的 URL
*/
String downloadMaterialUrl(Long accountId, String mediaId, String type);
MpMaterialDO uploadTemporaryMaterial(@Valid MpMaterialUploadTemporaryReqVO reqVO) throws IOException;
}

View File

@ -0,0 +1,133 @@
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.iocoder.yudao.module.infra.api.file.FileApi;
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;
import cn.iocoder.yudao.module.mp.dal.dataobject.material.MpMaterialDO;
import cn.iocoder.yudao.module.mp.dal.mysql.material.MpMaterialMapper;
import cn.iocoder.yudao.module.mp.framework.mp.core.MpServiceFactory;
import cn.iocoder.yudao.module.mp.service.account.MpAccountService;
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 org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.io.File;
import java.io.IOException;
/**
* 公众号素材 Service 接口
*
* @author 芋道源码
*/
@Service
@Validated
@Slf4j
public class MpMaterialServiceImpl implements MpMaterialService {
@Resource
private MpMaterialMapper mpMaterialMapper;
@Resource
private FileApi fileApi;
@Resource
@Lazy // 延迟加载解决循环依赖的问题
private MpAccountService mpAccountService;
@Resource
@Lazy // 延迟加载解决循环依赖的问题
private MpServiceFactory mpServiceFactory;
@Override
public String downloadMaterialUrl(Long accountId, String mediaId, String type) {
// 第一步直接从数据库查询如果已经下载直接返回
MpMaterialDO material = mpMaterialMapper.selectByAccountIdAndMediaId(accountId, mediaId);
if (material != null) {
return material.getUrl();
}
// 第二步尝试从临时素材中下载
String url = downloadMedia(accountId, mediaId);
if (url == null) {
return null;
}
MpAccountDO account = mpAccountService.getRequiredAccount(accountId);
material = MpMaterialConvert.INSTANCE.convert(mediaId, type, url, account)
.setPermanent(false);
mpMaterialMapper.insert(material);
// 不考虑下载永久素材因为上传的时候已经保存
return url;
}
@Override
public MpMaterialDO uploadTemporaryMaterial(MpMaterialUploadTemporaryReqVO reqVO) throws IOException {
WxMpService mpService = mpServiceFactory.getRequiredMpService(reqVO.getAccountId());
// 第一步上传到公众号
File file = null;
WxMediaUploadResult result;
String mediaId;
String url;
try {
// 写入到临时文件
file = FileUtil.newFile(FileUtil.getTmpDirPath() + reqVO.getFile().getOriginalFilename());
reqVO.getFile().transferTo(file);
// 上传到公众号
result = mpService.getMaterialService().mediaUpload(reqVO.getType(), file);
// 上传到文件服务
mediaId = ObjUtil.defaultIfNull(result.getMediaId(), result.getThumbMediaId());
url = uploadFile(mediaId, file);
} catch (WxErrorException e) {
// TODO yunai待完善
throw new RuntimeException(e);
} finally {
FileUtil.del(file);
}
// 第二步存储到数据库
MpAccountDO account = mpAccountService.getRequiredAccount(reqVO.getAccountId());
MpMaterialDO material = MpMaterialConvert.INSTANCE.convert(mediaId, reqVO.getType(), url, account)
.setPermanent(false);
mpMaterialMapper.insert(material);
return material;
}
/**
* 下载微信媒体文件的内容并上传到文件服务
*
* 为什么要下载媒体文件在微信后台保存时间为 3 3 天后 media_id 失效
*
* @param accountId 公众号账号的编号
* @param mediaId 媒体文件编号
* @return 上传后的 URL
*/
public String downloadMedia(Long accountId, String mediaId) {
WxMpService mpService = mpServiceFactory.getMpService(accountId);
for (int i = 0; i < 3; i++) {
try {
// 第一步从公众号下载媒体文件
File file = mpService.getMaterialService().mediaDownload(mediaId);
// 第二步上传到文件服务
return uploadFile(mediaId, file);
} catch (WxErrorException e) {
log.error("[mediaDownload][media({}) 第 ({}) 次下载失败]", mediaId, i);
}
}
return null;
}
private String uploadFile(String mediaId, File file) {
String path = mediaId + "." + FileTypeUtil.getType(file);
return fileApi.createFile(path, FileUtil.readBytes(file));
}
}

View File

@ -1,11 +1,8 @@
package cn.iocoder.yudao.module.mp.service.message;
import cn.hutool.core.io.FileTypeUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.infra.api.file.FileApi;
import cn.iocoder.yudao.module.mp.controller.admin.message.vo.MpMessagePageReqVO;
import cn.iocoder.yudao.module.mp.controller.admin.message.vo.MpMessageSendReqVO;
import cn.iocoder.yudao.module.mp.convert.message.MpMessageConvert;
@ -17,9 +14,11 @@ import cn.iocoder.yudao.module.mp.enums.message.MpMessageSendFromEnum;
import cn.iocoder.yudao.module.mp.framework.mp.core.MpServiceFactory;
import cn.iocoder.yudao.module.mp.framework.mp.core.util.MpUtils;
import cn.iocoder.yudao.module.mp.service.account.MpAccountService;
import cn.iocoder.yudao.module.mp.service.material.MpMaterialService;
import cn.iocoder.yudao.module.mp.service.message.bo.MpMessageSendOutReqBO;
import cn.iocoder.yudao.module.mp.service.user.MpUserService;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.api.WxConsts;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.bean.kefu.WxMpKefuMessage;
@ -31,7 +30,6 @@ import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import javax.validation.Validator;
import java.io.File;
/**
* 粉丝消息表 Service 实现类
@ -48,6 +46,8 @@ public class MpMessageServiceImpl implements MpMessageService {
private MpAccountService mpAccountService;
@Resource
private MpUserService mpUserService;
@Resource
private MpMaterialService mpMaterialService;
@Resource
private MpMessageMapper mpMessageMapper;
@ -56,9 +56,6 @@ public class MpMessageServiceImpl implements MpMessageService {
@Lazy // 延迟加载解决循环依赖的问题
private MpServiceFactory mpServiceFactory;
@Resource
private FileApi fileApi;
@Resource
private Validator validator;
@ -69,7 +66,6 @@ public class MpMessageServiceImpl implements MpMessageService {
@Override
public void receiveMessage(String appId, WxMpXmlMessage wxMessage) {
WxMpService mpService = mpServiceFactory.getRequiredMpService(appId);
// 获得关联信息
MpAccountDO account = mpAccountService.getAccountFromCache(appId);
Assert.notNull(account, "公众号账号({}) 不存在", appId);
@ -79,7 +75,7 @@ public class MpMessageServiceImpl implements MpMessageService {
// 记录消息
MpMessageDO message = MpMessageConvert.INSTANCE.convert(wxMessage, account, user);
message.setSendFrom(MpMessageSendFromEnum.USER_TO_MP.getFrom());
downloadMessageMedia(mpService, message);
downloadMessageMedia(message);
mpMessageMapper.insert(message);
}
@ -97,6 +93,7 @@ public class MpMessageServiceImpl implements MpMessageService {
// 记录消息
MpMessageDO message = MpMessageConvert.INSTANCE.convert(sendReqBO, account, user);
message.setSendFrom(MpMessageSendFromEnum.MP_TO_USER.getFrom());
// TODO 芋艿downloadMessageMedia
mpMessageMapper.insert(message);
// 转换返回 WxMpXmlOutMessage 对象
@ -124,7 +121,7 @@ public class MpMessageServiceImpl implements MpMessageService {
// 记录消息
MpMessageDO message = MpMessageConvert.INSTANCE.convert(wxMessage, account, user);
message.setSendFrom(MpMessageSendFromEnum.MP_TO_USER.getFrom());
downloadMessageMedia(mpService, message);
downloadMessageMedia(message);
mpMessageMapper.insert(message);
return message;
}
@ -132,38 +129,17 @@ public class MpMessageServiceImpl implements MpMessageService {
/**
* 下载消息使用到的媒体文件并上传到文件服务
*
* @param mpService 公众号 Service
* @param message 消息
*/
private void downloadMessageMedia(WxMpService mpService, MpMessageDO message) {
private void downloadMessageMedia(MpMessageDO message) {
if (StrUtil.isNotEmpty(message.getMediaId())) {
message.setMediaUrl(downloadMedia(mpService, message.getMediaId()));
message.setMediaUrl(mpMaterialService.downloadMaterialUrl(message.getAccountId(),
message.getMediaId(), MpUtils.getMediaFileType(message.getType())));
}
if (StrUtil.isNotEmpty(message.getThumbMediaId())) {
message.setThumbMediaUrl(downloadMedia(mpService, message.getThumbMediaId()));
message.setThumbMediaUrl(mpMaterialService.downloadMaterialUrl(message.getAccountId(),
message.getThumbMediaId(), WxConsts.MediaFileType.THUMB));
}
}
/**
* 下载微信媒体文件的内容并上传到文件服务
*
* 为什么要下载媒体文件在微信后台保存时间为 3 3 天后 media_id 失效
*
* @param mpService 微信公众号 Service
* @param mediaId 媒体文件编号
* @return 上传后的 URL
*/
private String downloadMedia(WxMpService mpService, String mediaId) {
try {
// 第一步从公众号下载媒体文件
File file = mpService.getMaterialService().mediaDownload(mediaId);
// 第二步上传到文件服务
String path = mediaId + "." + FileTypeUtil.getType(file);
return fileApi.createFile(path, FileUtil.readBytes(file));
} catch (WxErrorException e) {
log.error("[mediaDownload][media({}) 下载失败]", mediaId);
}
return null;
}
}

View File

@ -158,7 +158,7 @@ import {getMessagePage, sendMessage} from '@/api/mp/message'
}, {
...this.objData,
type: this.objData.repType,
content: this.objData.repContent,
// content: this.objData.repContent,
// TODO
})).then(response => {
this.sendLoading = false

View File

@ -4,15 +4,12 @@
-->
<template>
<el-tabs type="border-card" v-model="objData.repType" @tab-click="handleClick">
<!-- 类型 1文本 -->
<el-tab-pane name="text">
<span slot="label"><i class="el-icon-document"></i> 文本</span>
<el-input
type="textarea"
:rows="5"
placeholder="请输入内容"
v-model="objData.repContent">
</el-input>
<el-input type="textarea" :rows="5" placeholder="请输入内容" v-model="objData.content" />
</el-tab-pane>
<!-- 类型 2图片 -->
<el-tab-pane name="image">
<span slot="label"><i class="el-icon-picture"></i> 图片</span>
<el-row>
@ -29,19 +26,10 @@
<el-button type="success" @click="openMaterial">素材库选择<i class="el-icon-circle-check el-icon--right"></i></el-button>
</el-col>
<el-col :span="12" class="col-add">
<el-upload
:action="actionUrl"
:headers="headers"
multiple
:limit="1"
:on-success="handleUploadSuccess"
:file-list="fileList"
:before-upload="beforeImageUpload"
:data="uploadData">
<el-upload :action="actionUrl" :headers="headers" multiple :limit="1" :file-list="fileList" :data="uploadData"
:before-upload="beforeImageUpload" :on-success="handleUploadSuccess">
<el-button type="primary">上传图片</el-button>
<div slot="tip" class="el-upload__tip">
支持bmp/png/jpeg/jpg/gif格式大小不超过2M
</div>
<div slot="tip" class="el-upload__tip">支持 bmp/png/jpeg/jpg/gif 格式大小不超过 2M</div>
</el-upload>
</el-col>
</el-row>
@ -69,12 +57,7 @@
<el-button type="success" @click="openMaterial">素材库选择<i class="el-icon-circle-check el-icon--right"></i></el-button>
</el-col>
<el-col :span="12" class="col-add">
<el-upload
:action="actionUrl"
:headers="headers"
multiple
:limit="1"
:on-success="handleUploadSuccess"
<el-upload :action="actionUrl" :headers="headers" multiple :limit="1" :on-success="handleUploadSuccess"
:file-list="fileList"
:before-upload="beforeVoiceUpload"
:data="uploadData">
@ -188,9 +171,9 @@
},
props: {
objData:{
type:Object
type: Object
},
//12稿
// 12稿
newsType:{
type: String,
default: "1"