学成在线笔记十:媒资管理
媒资管理

视频处理

需求分析

原始视频通常需要经过编码处理,生成m3u8和ts文件方可基于HLS协议播放视频。通常用户上传原始视频,系统自动处理成标准格式,系统对用户上传的视频自动编码、转换,最终生成m3u8文件和ts文件,处理流程如下:

  1. 用户上传视频成功。
  2. 系统对上传成功的视频自动开始编码处理。
  3. 用户查看视频处理结果,没有处理成功的视频用户可在管理界面再次触发处理。
  4. 视频处理完成将视频地址及处理结果保存到数据库。

视频处理流程如下:

视频处理进程的任务是接收视频处理消息进行视频处理,业务流程如下:

  1. 监听MQ,接收视频处理消息。
  2. 进行视频处理。
  3. 向数据库写入视频处理结果

视频处理消费方

导入工程(省略)

application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server:
port: 31450
spring:
application:
name: xc-service-manage-media-processor
data:
mongodb:
uri: mongodb://root:123@localhost:27017
database: xc_media
#rabbitmq配置
rabbitmq:
host: 192.168.136.110
port: 5672
username: xcEdu
password: 123456
virtual-host: /
xc-service-manage-media:
mq:
queue-media-video-processor: queue_media_video_processor
routingkey-media-video: routingkey_media_video
video-location: ${MEDIA_FILE_LOCATION:E:/nginx/xcEdu/video/}
ffmpeg-path: F:/ffmpeg-20180227-fa0c9d6-win64-static/bin/ffmpeg.exe

RabbitMQConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package com.xuecheng.manage_media_process.config;

import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.amqp.SimpleRabbitListenerContainerFactoryConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* @author Administrator
* @version 1.0
* @create 2018-07-12 9:04
**/
@Configuration
public class RabbitMQConfig {

public static final String EX_MEDIA_PROCESSTASK = "ex_media_processor";

//视频处理队列
@Value("${xc-service-manage-media.mq.queue-media-video-processor}")
public String queue_media_video_processtask;

//视频处理路由
@Value("${xc-service-manage-media.mq.routingkey-media-video}")
public String routingkey_media_video;

//消费者并发数量
public static final int DEFAULT_CONCURRENT = 10;


/**
* 交换机配置
*
* @return the exchange
*/
@Bean(EX_MEDIA_PROCESSTASK)
public Exchange EX_MEDIA_VIDEOTASK() {
return ExchangeBuilder.directExchange(EX_MEDIA_PROCESSTASK).durable(true).build();
}

//声明队列
@Bean("queue_media_video_processtask")
public Queue QUEUE_PROCESSTASK() {
Queue queue = new Queue(queue_media_video_processtask, true, false, true);
return queue;
}

/**
* 绑定队列到交换机 .
*
* @param queue the queue
* @param exchange the exchange
* @return the binding
*/
@Bean
public Binding binding_queue_media_processtask(@Qualifier("queue_media_video_processtask") Queue queue, @Qualifier(EX_MEDIA_PROCESSTASK) Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(routingkey_media_video).noargs();
}


@Bean("customContainerFactory")
public SimpleRabbitListenerContainerFactory containerFactory(SimpleRabbitListenerContainerFactoryConfigurer configurer,
ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConcurrentConsumers(DEFAULT_CONCURRENT);
factory.setMaxConcurrentConsumers(DEFAULT_CONCURRENT);
configurer.configure(factory, connectionFactory);
return factory;
}

}

MediaProcessTask

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
package com.xuecheng.manage_media_process.mq;

import com.alibaba.fastjson.JSON;
import com.xuecheng.framework.domain.media.MediaFile;
import com.xuecheng.framework.domain.media.MediaFileProcess_m3u8;
import com.xuecheng.framework.domain.media.response.MediaCode;
import com.xuecheng.framework.exception.ExceptionCast;
import com.xuecheng.framework.utils.HlsVideoUtil;
import com.xuecheng.framework.utils.Mp4VideoUtil;
import com.xuecheng.manage_media_process.dao.MediaFileRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;

@Slf4j
@Component
public class MediaProcessTask {


//ffmpeg绝对路径
@Value("${xc‐service‐manage‐media.ffmpeg‐path}")
String ffmpeg_path;

//上传文件根目录
@Value("${xc‐service‐manage‐media.video‐location}")
String serverPath;

@Autowired
MediaFileRepository mediaFileRepository;


/**
* 接收视频处理消息并处理对应视频格式
*
* @param msg 消息
*/
@RabbitListener(queues = "${xc‐service‐manage‐media.mq.queue-media-video-processor}", containerFactory = "customContainerFactory")
public void receiveMediaProcessTask(String msg) {
Map<String, String> msgMap = JSON.parseObject(msg, Map.class);
log.info("[视频处理] 收到视频处理消息, msg = {}", msgMap.toString());

String mediaId = msgMap.get("mediaId");

// 获取文件
MediaFile mediaFile = mediaFileRepository.findById(mediaId).orElse(null);
if (mediaFile == null) {
ExceptionCast.cast(MediaCode.MEDIA_FILE_NOT_EXIST);
}
// 判断文件类型
String fileType = mediaFile.getFileType();
if (fileType == null || !fileType.equals("avi")) {//目前只处理avi文件
mediaFile.setProcessStatus("303004");//处理状态为无需处理
mediaFileRepository.save(mediaFile);
return;
} else {
mediaFile.setProcessStatus("303001");//处理状态为未处理
mediaFileRepository.save(mediaFile);
}

// 生成mp4
String mp4_name = mediaFile.getFileId() + ".mp4";
if (!buildMp4(mediaFile)) {
ExceptionCast.cast(MediaCode.MEDIA_BUILD_MP4_FAIL);
}

// 生成m3u8
if (!buildM3u8(mediaFile, mp4_name)) {
ExceptionCast.cast(MediaCode.MEDIA_BUILD_M3U8_FAIL);
}

log.info("[视频处理] 视频处理完成, mediaId = [{}]", mediaId);

}

/**
* 使用MP4文件生成m3u8文件并保存信息到数据库
*
* @param mediaFile 源文件
* @param mp4Name mp4文件名
* @return boolean
*/
private boolean buildM3u8(MediaFile mediaFile, String mp4Name) {
// mp4 url
String video_path = serverPath + mediaFile.getFilePath() + mp4Name;
// 生成后文件的存放位置
String m3u8_name = mediaFile.getFileId() + ".m3u8";
String m3u8folder_path = serverPath + mediaFile.getFilePath() + "hls/";
// 生成m3u8文件
HlsVideoUtil hlsVideoUtil = new HlsVideoUtil(ffmpeg_path, video_path, m3u8_name, m3u8folder_path);
String result = hlsVideoUtil.generateM3u8();
if (result == null || !result.equals("success")) {
//操作失败写入处理日志
processFail(result, mediaFile);
return false;
}
//获取m3u8列表
List<String> ts_list = hlsVideoUtil.get_ts_list();
//更新处理状态为成功
mediaFile.setProcessStatus("303002");//处理状态为处理成功
MediaFileProcess_m3u8 mediaFileProcess_m3u8 = new MediaFileProcess_m3u8();
mediaFileProcess_m3u8.setTslist(ts_list);
mediaFile.setMediaFileProcess_m3u8(mediaFileProcess_m3u8);
//m3u8文件url
mediaFile.setFileUrl(mediaFile.getFilePath() + "hls/" + m3u8_name);
mediaFileRepository.save(mediaFile);

return true;
}

/**
* 生成MP4文件
*
* @param mediaFile 源文件
* @return boolean
*/
private boolean buildMp4(MediaFile mediaFile) {
String video_path = serverPath + mediaFile.getFilePath() + mediaFile.getFileName();
String mp4_name = mediaFile.getFileId() + ".mp4";
String mp4folder_path = serverPath + mediaFile.getFilePath();
Mp4VideoUtil videoUtil = new Mp4VideoUtil(ffmpeg_path, video_path, mp4_name, mp4folder_path);
String result = videoUtil.generateMp4();
if (result == null || !result.equals("success")) {
//操作失败写入处理日志
processFail(result, mediaFile);
return false;
}

return true;
}

/**
* 操作失败
*
* @param result 操作结果
* @param mediaFile 文件
*/
private void processFail(String result, MediaFile mediaFile) {
mediaFile.setProcessStatus("303003");//处理状态为处理失败
MediaFileProcess_m3u8 mediaFileProcess_m3u8 = new MediaFileProcess_m3u8();
mediaFileProcess_m3u8.setErrormsg(result);
mediaFile.setMediaFileProcess_m3u8(mediaFileProcess_m3u8);
mediaFileRepository.save(mediaFile);
}


}

视频处理发送方

修改xc-service-manage-media相关代码

application.yml

新增配置

1
2
3
4
5
6
7
8
9
10
11
spring:
rabbitmq:
host: 192.168.136.110
port: 5672
username: xcEdu
password: 123456
virtual-host: /
xc-service-manage-media:
mq:
queue-media-video-processor: queue_media_video_processor
routingkey-media-video: routingkey_media_video

RabbitMQConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package com.xuecheng.manage_media.config;

import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* @author Administrator
* @version 1.0
* @create 2018-07-12 9:04
**/
@Configuration
public class RabbitMQConfig {

public static final String EX_MEDIA_PROCESSTASK = "ex_media_processor";

//视频处理队列
@Value("${xc-service-manage-media.mq.queue-media-video-processor}")
public String queue_media_video_processtask;

//视频处理路由
@Value("${xc-service-manage-media.mq.routingkey-media-video}")
public String routingkey_media_video;

//消费者并发数量
public static final int DEFAULT_CONCURRENT = 10;


/**
* 交换机配置
*
* @return the exchange
*/
@Bean(EX_MEDIA_PROCESSTASK)
public Exchange EX_MEDIA_VIDEOTASK() {
return ExchangeBuilder.directExchange(EX_MEDIA_PROCESSTASK).durable(true).build();
}

//声明队列
@Bean("queue_media_video_processtask")
public Queue QUEUE_PROCESSTASK() {
Queue queue = new Queue(queue_media_video_processtask, true, false, true);
return queue;
}

/**
* 绑定队列到交换机 .
*
* @param queue the queue
* @param exchange the exchange
* @return the binding
*/
@Bean
public Binding binding_queue_media_processtask(@Qualifier("queue_media_video_processtask") Queue queue, @Qualifier(EX_MEDIA_PROCESSTASK) Exchange exchange) {
return BindingBuilder.bind(queue).to(exchange).with(routingkey_media_video).noargs();
}
}

MediaUploadService

新增消息发送方法并在mergeChunks方法的最后调用该方法完成视频处理消息的发送。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
   @Value("${xc-service-manage-media.mq.routingkey-media-video}")
private String routingkey_media_video;

@Autowired
private RabbitTemplate rabbitTemplate;

/**
* 发送视频处理消息
*
* @param mediaId 视频id
*/
public void sendProcessVideoMsg(String mediaId) {
Optional<MediaFile> optional = mediaFileRepository.findById(mediaId);
if (!optional.isPresent()) {
ExceptionCast.cast(CommonCode.FAIL);
}
//发送视频处理消息
Map<String, String> msgMap = new HashMap<>();
msgMap.put("mediaId", mediaId);
//发送的消息
String msg = JSON.toJSONString(msgMap);
try {

this.rabbitTemplate.convertAndSend(RabbitMQConfig.EX_MEDIA_PROCESSTASK, routingkey_media_video, msg);
log.info("[发送视频处理消息] 文件上传完成, 发送视频处理消息. msg = {}", msg);
} catch (Exception e) {
log.info("[发送视频处理消息] 文件上传完成, 发送视频处理消息失败. msg = {}", msg, e.getMessage());
ExceptionCast.cast(CommonCode.FAIL);
}

}

测试

在课程管理前端选择文件并上传


查看控制台,正在执行MP4文件生成

查看最终生成的m3u8文件列表

我的媒资

需求分析

通过我的媒资可以查询本教育机构拥有的媒资文件,进行文件处理、删除文件、修改文件信息等操作,具体需求如下:

1、分页查询我的媒资文件

2、删除媒资文件

3、处理媒资文件

4、修改媒资文件信息

后端实现

MediaFileControllerApi

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.xuecheng.api.media;

import com.xuecheng.framework.domain.media.request.QueryMediaFileRequest;
import com.xuecheng.framework.model.response.QueryResponseResult;
import com.xuecheng.framework.model.response.ResponseResult;
import io.swagger.annotations.Api;

@Api(value="媒资管理接口",description="提供媒资文件数据的增删改查")
public interface MediaFileControllerApi {

/**
* 分页查询媒资文件列表
*
* @param page 当前页码
* @param size 每页记录数
* @param queryMediaFileRequest 查询条件
* @return QueryResponseResult
*/
QueryResponseResult findList(int page, int size, QueryMediaFileRequest queryMediaFileRequest);

/**
* 删除媒资文件
*
* @param id 媒资文件ID
* @return ResponseResult
*/
ResponseResult delete(String id);


}

MediaFileController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.xuecheng.manage_media.controller;

import com.xuecheng.api.media.MediaFileControllerApi;
import com.xuecheng.framework.domain.media.request.QueryMediaFileRequest;
import com.xuecheng.framework.model.response.CommonCode;
import com.xuecheng.framework.model.response.QueryResponseResult;
import com.xuecheng.framework.model.response.ResponseResult;
import com.xuecheng.framework.web.BaseController;
import com.xuecheng.manage_media.service.MediaFileService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("media/file")
public class MediaFileController extends BaseController implements MediaFileControllerApi {


@Autowired
private MediaFileService mediaFileService;

@Override
@GetMapping("list/{page}/{size}")
public QueryResponseResult findList(@PathVariable int page,
@PathVariable int size, QueryMediaFileRequest queryMediaFileRequest) {
return mediaFileService.findList(page, size, queryMediaFileRequest);
}

@Override
@DeleteMapping("{id}")
public ResponseResult delete(@PathVariable String id) {
mediaFileService.delete(id);
return new ResponseResult(CommonCode.SUCCESS);
}
}

MediaFileService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package com.xuecheng.manage_media.service;

import com.xuecheng.framework.domain.media.MediaFile;
import com.xuecheng.framework.domain.media.request.QueryMediaFileRequest;
import com.xuecheng.framework.model.response.CommonCode;
import com.xuecheng.framework.model.response.QueryResponseResult;
import com.xuecheng.framework.model.response.QueryResult;
import com.xuecheng.framework.service.BaseService;
import com.xuecheng.manage_media.dao.MediaFileRepository;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.ExampleMatcher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class MediaFileService extends BaseService {

@Autowired
private MediaFileRepository mediaFileRepository;

/**
* 分页查询媒资文件列表
*
* @param page 当前页码
* @param size 每页记录数
* @param queryMediaFileRequest 查询条件
* @return QueryResponseResult
*/
public QueryResponseResult findList(int page, int size, QueryMediaFileRequest queryMediaFileRequest) {
// 处理分页参数
if (page <= 0) {
page = 1;
}
page = page - 1;

// 处理分页查询条件
if (queryMediaFileRequest == null) {
queryMediaFileRequest = new QueryMediaFileRequest();
}
MediaFile mediaFile = new MediaFile();
if (StringUtils.isNotBlank(queryMediaFileRequest.getFileOriginalName())) {
mediaFile.setFileOriginalName(queryMediaFileRequest.getFileOriginalName());
}
if (StringUtils.isNotBlank(queryMediaFileRequest.getProcessStatus())) {
mediaFile.setProcessStatus(queryMediaFileRequest.getProcessStatus());
}
if (StringUtils.isNotBlank(queryMediaFileRequest.getTag())) {
mediaFile.setTag(queryMediaFileRequest.getTag());
}

ExampleMatcher exampleMatcher = ExampleMatcher.matching()
.withMatcher("fileOriginalName", ExampleMatcher.GenericPropertyMatchers.contains())
.withMatcher("tag", ExampleMatcher.GenericPropertyMatchers.contains());

Example<MediaFile> example = Example.of(mediaFile, exampleMatcher);

// 查询
Page<MediaFile> mediaFiles = mediaFileRepository.findAll(example, PageRequest.of(page, size));

QueryResult<MediaFile> queryResult = new QueryResult<>(mediaFiles.getContent(), mediaFiles.getTotalElements());
return new QueryResponseResult(CommonCode.SUCCESS, queryResult);
}

/**
* 删除指定ID的mediaFile
*
* @param id 媒资文件ID
*/
public void delete(String id) {
mediaFileRepository.deleteById(id);
}
}

前端实现

我这里前端主要修改了处理状态的下拉选择框的数据从数据字典动态获取。

修改media_list.vuemounted内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
mounted() {
//默认查询页面
this.query()
//初始化处理状态
//查询数据字典字典
this.processStatusList = [
{
id:'',
name:'全部'
}
]
// 查询数据库获取数据
systemApi.sys_getDictionary('303').then((res) => {
res.dvalue.forEach((element) => {
let data = {}
data.id = element.sdId
data.name = element.sdName
this.processStatusList.push(data)
})
})

}

注意:

还需要在前面引入查询数据字典的API定义:import * as systemApi from '../../../base/api/system'

在导入的数据库中会发现有两个数据字典的type都为303,查询会报错,所以我修改了另外一个的type403

媒资与课程计划关联

需求分析

  1. 进入课程计划修改页面。
  2. 选择视频。
  3. 选择成功后,将在课程管理数据库保存课程计划对应在的课程视频地址。

后端实现

修改xc-service-manage-course中相关代码完成功能

CoursePlanControllerApi

修增API定义

1
2
@ApiOperation("保存媒资信息")
ResponseResult saveMedia(TeachplanMedia teachplanMedia);

CoursePlanController

新增接口实现

1
2
3
4
5
6
7
@Override
@PostMapping("savemedia")
public ResponseResult saveMedia(@RequestBody TeachplanMedia teachplanMedia) {
TeachplanMedia saveMedia = courseService.saveMedia(teachplanMedia);
isNullOrEmpty(saveMedia, CommonCode.SERVER_ERROR);
return ResponseResult.SUCCESS();
}

CourseService

新增方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
   @Autowired
private TeachplanMediaRepository teachplanMediaRepository;

/**
* 保存课程计划关联媒资数据
*
* @param teachplanMedia 关联树数据
* @return TeachplanMedia
*/
public TeachplanMedia saveMedia(TeachplanMedia teachplanMedia) {
isNullOrEmpty(teachplanMedia, CommonCode.PARAMS_ERROR);
// 查询课程计划
Teachplan teachplan = coursePlanRepository.findById(teachplanMedia.getTeachplanId()).orElse(null);
isNullOrEmpty(teachplan, CourseCode.COURSE_MEDIS_TEACHPLAN_IS_NULL);

// 只允许叶子节点选择视频
String grade = teachplan.getGrade();
if (StringUtils.isEmpty(grade) || !grade.equals("3")) {
ExceptionCast.cast(CourseCode.COURSE_MEDIA_TEACHPLAN_GRADE_ERROR);
}
TeachplanMedia media;

Optional<TeachplanMedia> teachplanMediaOptional = teachplanMediaRepository.findById(teachplanMedia.getTeachplanId());
media = teachplanMediaOptional.orElseGet(TeachplanMedia::new);

//保存媒资信息与课程计划信息
media.setTeachplanId(teachplanMedia.getTeachplanId());
media.setCourseId(teachplanMedia.getCourseId());
media.setMediaFileOriginalName(teachplanMedia.getMediaFileOriginalName());
media.setMediaId(teachplanMedia.getMediaId());
media.setMediaUrl(teachplanMedia.getMediaUrl());

return teachplanMediaRepository.save(media);
}

TeachplanMediaRepository

1
2
3
4
5
6
7
package com.xuecheng.manage_course.dao;

import com.xuecheng.framework.domain.course.TeachplanMedia;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TeachplanMediaRepository extends JpaRepository<TeachplanMedia, String> {
}

前端实现

前端基本上已经全部实现了,我这里有一点修改因为我自己太欠了,当时写CoursePlan的时候用的根路径是是course/teachplan

我需要在调用的API地址前面加上teachplan

1
2
3
4
/*保存媒资信息*/
export const savemedia = teachplanMedia => {
return http.requestPost(apiUrl+'/course/teachplan/savemedia',teachplanMedia);
}

视频信息回显(省略)

注意:

因为数据库中的courseId的确切字段名为:courseid而不是courseId,需要在实体类上加入如下代码:

1
2
@Column(name="courseid")
private String courseId;

代码获取

代码获取

文章作者: imxushuai
文章链接: https://www.imxushuai.com/2020/06/10/28.学成在线笔记十:媒资管理/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 imxushuai
支付宝打赏
微信打赏