前段时间做视频上传业务,通过网页上传视频到服务器。
视频大小 小则几十M,大则 1G+,以一般的HTTP请求发送数据的方式的话,会遇到的问题:1,文件过大,超出服务端的请求大小限制;2,请求时间过长,请求超时;3,传输中断,必须重新上传导致前功尽弃;
解决方案:
1,修改服务端上传的限制配置;Nginx 以及 PHP 的上传文件限制 不宜过大,一般5M 左右为好;
2,大文件分片,一片一片的传到服务端,再由服务端合并。这么做的好处在于一旦上传失败只是损失一个分片而已,不用整个文件重传,而且每个分片的大小可以控制在4MB以内,服务端限制在4M即可。
前端使用UP6大文件上传控件
<div class="section section6 section5">
<div class="part1"><a href="javascript:;" target="_blank" class="part1__btn">批量删除</a><span class="part1__txt"><em class="part1__num" id="upload_num">0</em>个视频,共 <em class="part1__num" id="upload_size">0M</em></span></div>
<table class="section5__table">
<tbody id="thelist">
<tr class="thead">
<th class="col1 allCkeck"><input type="checkbox" name="" class="col1__checkBox"/>视频名称</th><th class="col2">视频大小</th><th class="col3">视频分类</th><th class="col4">状态</th><th class="col5">进度</th><th>操作</th>
</tr>
</tbody>
</table>
<div class="selFile" id="selFile">
<div id="drag_tips">
<div id="btns__add2"></div>
<h2 class="txt1">选择视频文件</h2>
<span class="txt2">或直接将文件拖拽至此窗口</span>
</div>
</div>
<div class="btns"><span class="btns__add" id="btns__add">+添加视频文件</span><span class="btns__upload btns__upload-start" id="uploadBtn"><i class="btns__upload_icon"></i>开始上传视频</span></div>
</div>
//引入插件
<script type="text/javascript" src="media/js/lib/webuploader/js/webuploader.min.js"></script>
upload.js
1 // 文件上传
2 jQuery(function() {
3 var $ = jQuery,
4 $list = $('#thelist'),
5 $btn = $('#upload-start'),
6 $thead = $('.thead'),
7 $part_btn = $('.part1__btn'), //批量上传按钮
8 state = 'pending',
9 fileCount = 0, //上传文件总数
10 fileSize = 0,//上传文件的总大小
11 // 上传按钮
12 $upload = $('#uploadBtn'),
13 // 所有文件的进度信息,key为file id
14 percentages = {},
15 // 所有文件的md5,key为file id
16 md5Obj = {},
17 // 可能有pedding, ready, uploading, confirm, done.
18 state = 'pedding',
19 uploader;
20
21 //浏览器关闭提醒
22 window.is_confirm = false;
23 $(window).bind('beforeunload', function(){
24 // 只有在标识变量is_confirm不为false时,才弹出确认提示
25 if(window.is_confirm !== false)
26 return '正在上传视频,该操作将丢失视频,是否继续?';
27 })
28
29 if ( !WebUploader.Uploader.support() ) {
30 alert( 'Web Uploader 不支持您的浏览器!如果你使用的是IE浏览器,请尝试升级浏览器');
31 throw new Error( 'WebUploader does not support the browser you are using.' );
32 }
33
34 $(".pop2 .btns__sure").click(function(){
35 $('.popup,.pop').hide();
36 });
37
38 uploader = WebUploader.create({
39 //拖拽容器
40 dnd:'#selFile',
41
42 // 不压缩image
43 resize: false,
44
45 // swf文件路径
46 swf: '/media/js/lib/webuploader/js/Uploader.swf',
47
48 // 文件接收服务端。
49 server: '/service/upload/upload_file',
50 //server:'http://vod.test.4399sy.com/service/upload/ssl_upload_file',
51 formData: {
52 file_id: 'file',
53 guid:new Date().getTime() + Math.ceil(Math.random()*100),
54 file_name:''
55 },
56
57 // 选择文件的按钮。可选。
58 // 内部根据当前运行是创建,可能是input元素,也可能是flash.
59 pick: {
60 id:'#btns__add',
61 innerHTML:"+添加视频文件"
62 },
63
64 // 开起分片上传。
65 chunked: true,
66
67 //如果要分片,分多大一片2M
68 chunkSize:2*1024*1024,
69
70 //上传文件的类型
71 accept:{
72 title: 'Videos',
73 extensions: 'mp4,avi,flv',
74 mimeTypes: 'video/*'
75 },
76 //验证文件总数量, 超出则不允许加入队列。
77 fileNumLimit: 10,
78 //单个文件上传的大小限制 2G
79 fileSingleSizeLimit:2*1024*1024*1024,
80
81 });
82
83 //添加文件具体函数
84 function addFile( file ){
85 var data = new Date(),
86 month = (data.getMonth()+1)<10 ? '0'+(data.getMonth()+1) : (data.getMonth()+1),
87 day = data.getDate()<10 ? '0'+ data.getDate(): data.getDate(),
88 time = data.getFullYear() + "-" + month + "-" + day,
89 $tr = $('<tr class="toBeUploaded" id="'+file.id+'"></tr>'),
90 $td = $('<td class="col1"><input type="checkbox" name="" class="col1__checkBox"/><input type="text" value="'+ file.name +'" name="" class="name"/></td><td class="col2">'+convert_size(file.size)+'</td><td class="col3"><select class="class_id">'+ class_options +'</select></td><td class="col4">读取视频中</td><td class="col5">0%</td><td class="col6"><ul><li class="view"><a target="_blank" href="javascript:;">查看</a></li><li class="delete">删除</li></ul></td>').appendTo($tr),
91 $state = $tr.find('td.col4'),
92 $prgress = $tr.find('td.col5'),
93 $delbtn = $tr.find('li.delete');
94
95 $("#selFile").hide();
96
97 if ( file.getStatus() === 'invalid' ) {
98 switch( file.statusText ) {
99 case 'exceed_size':
100 text = '文件大小超出';
101 break;
102
103 case 'interrupt':
104 text = '上传暂停';
105 break;
106
107 default:
108 text = '上传失败,请重试';
109 break;
110 }
111 showError(text);
112 } else {
113 // @todo lazyload
114 percentages[ file.id ] = [ file.size, 0 ];
115 file.rotation = 0;
116 }
117
118 file.on('statuschange', function( cur, prev ) {
119 if ( prev === 'progress' ) {
120 //上传成功
121 } else if ( prev === 'queued' ) {
122 // 开始上传
123 }
124
125 // 成功
126 if ( cur === 'error' || cur === 'invalid' ) {
127 console.log( file.statusText );
128 showError( file.statusText );
129 percentages[ file.id ][ 1 ] = 1;
130 } else if ( cur === 'interrupt' ) {
131 showError( 'interrupt' );
132 } else if ( cur === 'queued' ) {
133 percentages[ file.id ][ 1 ] = 0;
134 } else if ( cur === 'progress' ) {
135 // 正在上传
136
137 } else if ( cur === 'complete' ) {
138 // 上传完成
139
140 }
141
142 $tr.removeClass( 'state-' + prev ).addClass( 'state-' + cur );
143 });
144 $delbtn.on('click',function(){
145 uploader.removeFile( file );
146 });
147 $tr.appendTo($list);
148 //$tr.insertAfter($thead);
149 }
150
151 // 负责view的销毁
152 function removeFile( file ) {
153 var $tr = $('#'+file.id);
154
155 delete percentages[ file.id ];
156 $tr.off().find('.col6').off().end().remove();
157 }
158
159 function setState( val ) {
160 var file, stats;
161
162 if ( val === state ) {
163 return;
164 }
165
166 $upload.removeClass( 'state-' + state );
167 $upload.addClass( 'state-' + val );
168 state = val;
169
170 switch ( state ) {
171 case 'pedding':
172 uploader.refresh();
173 break;
174
175 case 'ready':
176 uploader.refresh();
177 break;
178
179 case 'uploading':
180 $upload.text( '暂停上传' );
181 break;
182 case 'paused':
183 $upload.text( '继续上传' );
184 break;
185
186 case 'confirm':
187 //$progress.hide();
188 $upload.text( '开始上传' ).addClass( 'disabled' );
189
190 stats = uploader.getStats();
191 if ( stats.successNum && !stats.uploadFailNum ) {
192 setState( 'finish' );
193 return;
194 }
195 break;
196 case 'finish':
197 stats = uploader.getStats();
198 if ( stats.successNum ) {
199 alert( '上传成功' );
200 } else {
201 // 没有成功的图片,重设
202 state = 'done';
203 location.reload();
204 }
205 break;
206 }
207 }
208
209
210 // 当有文件添加进来的时候
211 uploader.on( 'fileQueued', function( file ) {
212 fileCount++;
213 fileSize += file.size;
214 $("#upload_num").text(fileCount);
215 $("#upload_size").text(convert_size(fileSize));
216 md5Obj[ file.id ] = '';
217 //获取文件MD5 值
218 uploader.md5File( file )
219 // 及时显示进度
220 .progress(function(percentage) {
221 $( '#'+file.id ).find('.col4').text('读取文件'+parseInt(percentage*100)+"%");
222 })
223 // 完成
224 .then(function(val) {
225 console.log('md5 result:', val);
226 md5Obj[ file.id ] = val;
227 $( '#'+file.id ).find('.col4').text('待上传');
228 setState( 'ready' );
229 });
230 addFile( file );
231 });
232
233 // 删除文件
234 uploader.onFileDequeued = function( file ) {
235 fileCount--;
236 fileSize -= file.size;
237 $("#upload_num").text(fileCount);
238 $("#upload_size").text(convert_size(fileSize));
239 if ( !fileCount ) {
240 setState( 'pedding' );
241 }
242 removeFile( file );
243
244 };
245
246 // 添加“添加文件”的按钮,
247 uploader.addButton({
248 id: '#btns__add2',
249 label: ''
250 });
251
252 // 文件上传过程中创建进度实时显示。
253 uploader.onUploadProgress = function( file, percentage ) {
254 var $tr = $('#'+file.id),
255 $percent = $tr.find('td.col5'),
256 $state = $tr.find('td.col4');
257 percentage = parseInt(percentage*100);
258 if(! (percentage == 0 && percentage == 100)){
259 $state.text("正在上传");
260 }
261 $percent.text( percentage + "%")
262 percentages[ file.id ][ 1 ] = percentage;
263 };
264
265 //上传前,请求服务端 判断文件是否已经上传过
266 uploader.on( 'uploadStart', function( file ) {
267 var type = 'POST';
268 var url = '/service/upload/determine_video_exist';
269 var request_data = {
270 'md5': md5Obj[ file.id ],
271 'type':1
272 };
273 var success = function(r) {
274 uploader.upload( file );
275 console.log(r);
276 if(r.code == 1) {
277 uploader.skipFile( file );
278 $( '#'+file.id ).find('.col4').text('视频已存在');
279 $( '#'+file.id ).find('.col5').text('100%');
280 $('#'+file.id).find('.view').find('a').attr('href',playmain +'/?video_id='+ r.data.video_id);
281 $('.pop2 .video_game').text("所在游戏:"+r.data.game_name);
282 $('.pop2 .create_time').text("上传时间:"+r.data.create_time);
283 $('.pop').hide();
284 $('.pop2').show();
285 $('.popup').show();
286 }else if(r.code <= 0) {
287 showError(r.msg);
288 }else {
289
290 }
291 };
292 request(type, url, request_data, success);
293 });
294
295 //当某个文件的分块在发送前触发,主要用来询问是否要添加附带参数,大文件在开起分片上传的前提下此事件可能会触发多次。
296 uploader.on('uploadBeforeSend', function (obj, data, headers) {
297 $tr = $("#"+data.id);
298 var file_name = $tr.find(".name").val();
299 var class_id = $tr.find("select.class_id").val();
300 var reg = /[1-9][0-9]*/g;
301 data.md5 = md5Obj[ obj.file.id ];
302 data.file_name = file_name;
303 data.class_id = class_id;
304 data.guid = data.guid + data.id.replace(/[^0-9]+/g, '');
305 });
306
307 uploader.on( 'uploadSuccess', function( file ,res) {
308 if(res.code == 1){
309 $( '#'+file.id ).find('.col4').text('成功上传');
310 console.log(res);
311 $('#'+file.id).find('.view').find('a').attr('href',playmain +'/?video_id='+ res.data.video_id);
312 }else if(res.code == 2) {
313 $( '#'+file.id ).find('.col4').text('视频已存在');
314 console.log(res);
315 $('#'+file.id).find('.view').find('a').attr('href',playmain +'/?video_id='+ res.data.video_id);
316 }else {
317 showError(res.msg);
318 }
319 });
320
321 uploader.on( 'uploadError', function( file,reason ) {
322 $( '#'+file.id ).find('.col4').text('上传失败');
323 console.log(reason);
324 });
325
326 uploader.on( 'uploadComplete', function( file ) {
327 $( '#'+file.id ).find('.progress').fadeOut();
328 });
329
330 uploader.on( 'all', function( type ) {
331 if ( type === 'startUpload' ) {
332 state = 'uploading';
333 } else if ( type === 'stopUpload' ) {
334 state = 'paused';
335 } else if ( type === 'uploadFinished' ) {
336 state = 'done';
337 }
338
339 if ( state === 'uploading' ) {
340 window.is_confirm = true;
341 $('.toBeUploaded').addClass("uploaded").removeClass("toBeUploaded");
342 $('input.name').attr("disabled","disabled");
343 $('input.col1__checkBox').hide();
344 $('input').attr("disabled","disabled");
345 $('select.class_id').attr("disabled","disabled");
346 $('.btns__add').remove();
347 $upload.addClass("btns__upload-ing").removeClass("btns__upload-start").html('<i class="btns__upload_icon"></i>正在上传视频');
348
349 } else if(state === 'done') {
350 window.is_confirm = false;
351 console.log("上传完成");
352 $upload.addClass("btns__upload-start btns__upload-refresh").removeClass("btns__upload-ing").html('<i class="btns__upload_icon"></i>开始上传视频');
353 }
354 });
355
356 /**
357 * 验证文件格式以及文件大小
358 */
359 uploader.on("error",function (type){
360 var msg = ''
361 switch (type){
362 case "Q_TYPE_DENIED": msg = "请上传mp4格式文件";break;
363 case "F_EXCEED_SIZE": msg = "文件大小不能超过1G";break;
364 case "Q_EXCEED_NUM_LIMIT" : msg = "一次最多能上传10个文件";break;
365 default: msg='';
366 }
367 if(msg != ''){
368 showError(msg);
369 }
370 });
371
372 $part_btn.on('click',function(){
373 $('td .col1__checkBox').each(function(){
374 if($(this).is(':checked')){
375 var $tr = $(this).parents('tr');
376 var id = $tr.attr('id');
377 uploader.removeFile( id );
378 }
379 });
380 });
381 $upload.on('click', function() {
382 var isbreak = false;
383 $(".name").each(function(){
384 if(!$(this).val()|| $(this).val() == ''){
385 isbreak = true;
386 }
387 })
388 if(isbreak){
389 showError("文件名不能存在为空");
390 return;
391 }
392 $("select.class_id").each(function(){
393 if(!$(this).val()|| $(this).val() == ''){
394 isbreak = true;
395 }
396 })
397 if(isbreak){
398 showError("分类不能为空,请先添加分类");
399 return;
400 }
401 if ( $(this).hasClass( 'btns__upload-refresh' ) ) {
402 location.reload();
403 }
404 if ( $(this).hasClass( 'btns__upload-ing' ) ) {
405 return false;
406 }
407 var md5Ready = true;
408 $.each(md5Obj,function(index,item){
409 if(!item || item==''){
410 md5Ready = false;
411 }
412 });
413 if(!md5Ready){
414 showError('文件尚未读取完成,请耐心等待');
415 return false;
416 }
417 if ( state === 'ready' && md5Ready ) {
418 uploader.upload();
419 } else if ( state === 'paused' ) {
420 uploader.upload();
421 } else if ( state === 'uploading' ) {
422 uploader.stop();
423 }
424 });
425 $upload.addClass( 'state-' + state );
426
427 });
后台(PHP)【仅分片上传相关代码】
1 public function action_upload_file(){
2 $file_id = R::string('file_id', 'file');
3 $keepFileName = R::string('keepFileName', 0);
4 $unsize_change = R::numeric('unsize_change',0);
5 $id = R::string('id'); //插件每上传一个视频自带id
6 $guid = R::string('guid'); //标识视频
7 $chunks = R::numeric('chunks'); // 分片数
8 $chunk = R::numeric('chunk'); //分片号
9 $file_name = R::string('file_name');
10 $file = isset($_FILES[$file_id])?$_FILES[$file_id]:'';
11 $md5 = R::string('md5');
12 $this->upload = new Common_Upload();
13
14 if(empty($guid) || empty($file_name) || empty($md5)){
15 $this->response_msg(-1, 'guid 或 file_name 或 md5 不能为空');
16 return;
17 }
18
19 if(empty($file['name'])){
20 $this->response_msg(-1, '请上传一个文件');
21 return;
22 }else{
23 if($chunks){
24 $res = $this->upload->saveFile_chunks($file,$chunks, $chunk, $guid);
25 if(empty($res)){
26 $this->response_msg(-2, '分片上传失败');
27 return;
28 }
29
30 }else if($keepFileName){
31 $res = $this->upload->saveFile_nochunks($file, '', '', $keepFileName);
32 }else{
33 $res = $this->upload->saveFile_nochunks($file);
34 }
35 if(empty($res)){
36 $err = $this->upload->getError();
37 $this->response_msg(-3, '上传文件出错!msg:'.print_r($err, true));
38 return;
39 }
40 if($unsize_change){
41 $size = $res['size'];
42 }else{
43 $size = $this->convert_size($res['size']);
44 }
45
46 //视频上传完成
47 if($chunks && $res['last_chunk']){
48 $domain = Kohana::$config->load('domain');
49 $video_domain = $domain[RUN_MOD]['VIDEO'];
50
51 if(!empty($file_name)){
52 $res['name'] = $file_name;
53 }
54 $video_data = array(
55 'video_name'=> $file_name,
56 'video_url'=> $video_domain."/".$res['path'],
57 'size'=>$res['size'],
58 'create_time'=> date('y-m-d H:i:s',time()),
59 'update_time'=> date('y-m-d H:i:s',time()),
60 'duration'=> $res['time'],
61 'md5'=>$md5
62 );
63 $video_mod = new Model_Videoinfo();
64 $video = $video_mod->save_video($video_data,$guid);
65 $res = array(
66 'path'=>$res['path'],
67 'chunks'=>$chunks,
68 'chunk'=>$chunk,
69 'size'=>$size,
70 'guid'=> $guid,
71 'video_id'=>$video[0],
72 'file'=>$file,
73 'id'=>$id
74 );
75 $this->response_msg(1,'视频上传成功',$res);
76 return;
77 }
78 //非分片上传
79 if(!$chunks){
80 $domain = Kohana::$config->load('domain');
81 $video_domain = $domain[RUN_MOD]['VIDEO'];
82 if(!empty($file_name)){
83 $res['name'] = $file_name;
84 }
85 $video_data = array(
86 'video_name'=> $file_name,
87 'video_url'=> $video_domain."/".$res['path'],
88 'size'=>$res['size'],
89 'create_time'=> date('y-m-d H:i:s',time()),
90 'update_time'=> date('y-m-d H:i:s',time()),
91 'duration'=> $res['time'],
92 'md5'=>$md5
93 );
94 $video_mod = new Model_Videoinfo();
95 $video = $video_mod->save_video($video_data,$guid);
96 if(empty($video)){
97 $this->response_msg(-6, '视频信息保存失败');
98 return;
99 }
100 $res = array(
101 'path'=>$res['path'],
102 'video_data'=>$video_data,
103 'size'=>$size,
104 'guid'=> $guid,
105 'video_id'=>$video[0],
106 'file'=>$file,
107 'id'=>$id
108 );
109 $this->response_msg(1,'视频上传成功',$res);
110 return;
111 }
112 //分片上传成功(未全部分片上传完成)
113 $res = array(
114 'chunks'=>$chunks,
115 'chunk'=>$chunk,
116 );
117 $this->response_msg(2, '分片上传成功',$res);
118 }
119 }
1 /**
2 * 保存分片文件(注意先验证文件是否合法)
3 *
4 * @param array $file 单个文件
5 * @param string $attachdir 上传文件路径
6 * @param string $upload_type 上传文件类型
7 * @param bool $keepFileName 是否保持文件名,默认不不保持
8 * @return bool
9 */
10 public function saveFile_chunks($file,$chunks, $chunk, $guid)
11 {
12 if(empty($guid) || empty($file) ){
13 return false;
14 }
15 $file_name = (string)$guid . $chunk;
16 //保存分片文件
17 $file_info = $this->saveFile($file, '', '', false, $file_name,true);
18 if ($file_info) {
19 $cache = Cache::instance('memcache');
20 //记录已上传的分片编号,上传顺序并不是按编号顺序进行上传
21 $chunks_list_pre = $cache->get($guid);
22 if(empty($chunks_list_pre)){
23 $strarr = array();
24 for($i=0;$i<$chunks;$i++){
25 $strarr[] = $guid.$i;
26 }
27 $cache->set($guid,$strarr,60 * 60 * 24);
28 }
29 $file_path = $cache->set($guid.$chunk,$file_info['path'],60 * 60 * 24);
30
31 $chunk_path_array= array();
32 for($i=0;$i<$chunks;$i++){
33 if($cache->get($guid.$i)){
34 $chunk_path_array[$i] = $cache->get($guid.$i);
35 }
36 }
37 list($Y,$M,$D,$H,$I,$S) = explode('-',date("Y-m-d-H-i-s", time()));
38 $file_info['chunks_path_count'] = count($chunk_path_array);
39 $file_info['last_chunk'] = false;
40 if (count($chunk_path_array) == $chunks) {
41 //按目录类型存储
42 $dirType = substr($file_info['type'], 1, strlen($file_info['type']));;
43 //目录类型前面加上前缀url
44 $dirType = $this->pre_url.$dirType;
45 //按年月二级存储
46 $month_file_path = $Y.'/'.$M;
47 $saveName ='upload/mp4/'.$month_file_path.'/original/'.$guid.$file_info['type'];
48 $join_file_name =$this->attachDIR.$saveName;
49 if(!is_dir($this->attachDIR.'upload/mp4/'.$month_file_path.'/original/')){
50 mkdir($this->attachDIR.'upload/mp4/'.$month_file_path.'/original/',0755,true);
51 }
52 if(! file_exists($join_file_name)){
53 $fp = fopen($join_file_name, "ab");
54 //合并过程中对文件加锁,防止同时操作而出错
55 if (flock($fp,LOCK_EX)){
56 for ($i = 0; $i < $chunks; $i++) {
57 $tmp_file = $this->attachDIR . $chunk_path_array[$i];
58 $handle = fopen($tmp_file, "rb");
59 fwrite($fp, fread($handle, filesize($tmp_file)));
60 fclose($handle);
61 unset($handle);
62 unlink($tmp_file);//合并完毕的文件就删除
63 }//组装分片
64 $cache->delete($guid);
65 for($i=0;$i<$chunks;$i++){
66 $cache->delete($guid.$i);
67 }
68 $time = $this->getTime($join_file_name,$file_info['type']);
69 $file_info['time'] = $time;
70 $file_info['path'] = $saveName;
71 $file_info['size'] = filesize($join_file_name);
72 $file_info['last_chunk'] = true;
73
74 $model_mod = new Model_Base();
75 $model_mod->disconnect();
76 $pid = pcntl_fork();
77 //父进程和子进程都会执行下面代码
78 if ($pid == -1) {
79 //错误处理:创建子进程失败时返回-1.
80 die('could not fork');
81 } else if ($pid) {
82 $model_mod->connect();
83 //对上传完成的视频进行排队转码
84 $this->thread($join_file_name,$file_info['type'],$guid);
85 //父进程会得到子进程号,所以这里是父进程执行的逻辑
86 pcntl_wait($status); //等待子进程中断,防止子进程成为僵尸进程。
87 } else {
88 return $file_info;
89 //子进程得到的$pid为0, 所以这里是子进程执行的逻辑。
90 }
91
92 }
93 }
94
95 }
96 return $file_info;
97 } else {
98 $this->error[] = '分片上传失败';
99 return false;
100 }
101 /*}}}*/
102 }
1,实现了分片上传;
2,同时在上传前检查视频md5 是否在库,如已存在可实现“秒传” 功能,即直接复制数据信息,指向同一个文件,不必再上传;
3,可实现断点续传,上传过程中中断;之前上传的分片已保留在服务器,只需重新上传尚未上传的分片即可;
参考文章:http://blog.ncmem.com/wordpress/2019/08/12/java-http%E5%A4%A7%E6%96%87%E4%BB%B6%E6%96%AD%E7%82%B9%E7%BB%AD%E4%BC%A0%E4%B8%8A%E4%BC%A0/
欢迎入群一起讨论:374992201
来源:oschina
链接:https://my.oschina.net/u/4351890/blog/4271990