moment github地址html
在上一篇文章中,主要是对项目作了介绍,而且对系统分析和系统设计作了大概的介绍。那么接下来这篇文章会对系统的实现作介绍,主要是选择一些比较主要的模块或者说可拿出来与你们分享的模块。好了,接入正题吧~~node
服务端这边使用的是Express框架,数据库使用的是MongoDB,经过Mongoose模块来操做数据库。这边主要是想下对MongoDB作个介绍,固然看官了解的话直接往下划~~~~~~ios
在项目开始前要确保电脑是否安装mongoDB,下载点我,图像化工具Robo 3T 点我,下载好具体怎么配置还请问度娘或Google吧,本文不作介绍了哈。注意:安装完mongoDB的时候进行项目时要把lib目录下的mongod服务器打开哈~~git
MongoDB 是一个基于分布式文件存储的数据库,是一个介于关系型数据库和非关系型数据库之间的开源产品,它是功能最为丰富的非关系型数据库,也是最像关系型数据库的。可是和关系型数据库不一样,MongoDB没有表和行的概念,而是一个面向集合、文档
的数据库。其中的文档是一个键值对,采用BSON(Binary Serialized Document Format),BSON是一种相似于JSON的二进制形式的存储格式,而且BSON具备表示数据类型的扩展,所以支持的数据很是丰富。MongoDB有两个很重要的数据类型就是内嵌文档和数组
,并且在数组内能够嵌入其余文档,这样一条记录就能表示很是复杂的关系。github
Mongoose是在node.js异步环境下对MongoDB进行简便操做的对象模型工具,能从数据库提取任何信息,能够用面向对象的方法来读写数据,从而使操做MongoDB数据库很是便捷。Mongoose中有三个很是重要的概念,即是Schema(模式),Model(模型),Entity(实体)。web
//Schema const mongoose = require('mongoose'); const Schema = mongoose.Schema; const UserSchema = new Schema({ token: String, is_banned: {type: Boolean, default: false}, //是否禁言 enable: { type: Boolean, default: true }, //用户是否有效 is_actived: {type: Boolean, default: false}, //邮件激活 username: String, password: String, email: String, //email惟一性 code: String, email_time: {type: Date}, phone: {type: String}, description: { type: String, default: "这我的很懒,什么都没有留下..." }, avatar: { type: String, default: "http://p89inamdb.bkt.clouddn.com/default_avatar.png" }, bg_url: { type: String, default: "http://p89inamdb.bkt.clouddn.com/FkagpurBWZjB98lDrpSrCL8zeaTU"}, ip: String, ip_location: { type: Object }, agent: { type: String }, // 用户ua last_login_time: { type: Date }, ..... }); 复制代码
//生成一个具体User的model并导出 const User = mongoose.model("User", UserSchema); //第一个参数是集合名,在数据库中会把Model名字字母所有变小写和在后面加复数s //执行到这个时候你的数据库中就有了 users 这个集合 module.exports = User; 复制代码
const newUser = new UserModel({ //UserModel 为导出来的 User
email: req.body.email,
code: getCode(),
email_time: Date.now()
});
复制代码
Mongoose中有一个东西我的感受很是主要,那即是populate
,经过populate他能够很方便的与另外一个集合创建关系。以下,user集合能够与article集合、user集合自己进行关联,根据其内嵌文档的特性,这样子他即可之内嵌子文档,子文档中有能够内嵌子文档,这样子它返回的数据就会异常的丰富。
const user = await UserModel.findOne({_id: req.query._id, is_actived: true}, {password: 0}).populate({ path: 'image_article', model: 'ImageArticle', populate: { path: 'author', model: 'User' } }).populate({ path: 'collection_film_article', model: 'FilmArticle', }).populate({ path: 'following_user', model: 'User', }).populate({ path: 'follower_user', model: 'User', }).exec(); 复制代码
服务端主要是操做数据库,对数据库进行增删改查(CRUD)等操做。项目中的接口,Mongoose的各类方法这边就不对其作详细介绍,你们能够查看Mongoose文档。
本系统的用户身份认证机制采用的是JSON Web Token(JWT)
,它是一种轻量的认证规范,也用于接口的认证。咱们知道,HTTP协议是一种无状态的协议,这便意味着每一个请求都是独立的,当用户提供了用户名和密码来对咱们的应用进行用户认证,那么在下一次请求的时候,用户须要再进行一次用户的认证才能够,由于根据HTTP协议,咱们并不能知道是哪一个用户发出的请求,本系统采用了token的鉴权机制。这个token必需要在每次请求时传递给服务端,它应该保存在请求头里,另外,服务端要支持CORS(跨来源资源共享)策略,通常咱们在服务端这么作就能够了Access-Control-Allow-Origin: *。
在用户身份认证这一块有不少方法,最多见的像cookie ,session。那么他们三之间又有什么区别,这里有两篇文章介绍的挺全面。
token 与 session的区别在于,它不一样于传统的session认证机制,它不须要在服务端去保留用户的认证信息或其会话的信息。系统一旦比较大,都会采用机器集群来作负载均衡,这须要多台机器,因为session是保存在服务端,那么就要 去考虑用户究竟是在哪一台服务器上进行登陆的,这即是一个很大的负担。
那么就有人想问了,你这个系统这么小,为何不使用传统的session机制呢?哈~由于以前本身的项目通常都是使用session作登陆,没使用过token,想尝试尝试入入坑~~哈哈哈~
JWT主要的实现思路以下:
在用户登陆成功的时候建立token保存于数据库中,并返回给客户端。
客户端以后的每一次请求都要带上token,在请求头里加入Authorization,并加上token.
在服务端进行验证token的有效性,在有效期内返回200状态码,token过时则返回401状态码
以下图所示:
在node中主要用了jsonwebtoken
这个模块来建立JWT,jsonwebtoken的使用请查看jsonwebtoken文档。项目中建立token的中间件createToken以下
/**
* createToken.js
*/
const jwt = require('jsonwebtoken'); // 引入jsonwebtoken模块
const secret = '我是密钥'
//登陆时:核对用户名和密码成功后,应用将用户的id(user_id)做为JWT Payload的一个属性
module.exports = function(user_id){
const token = jwt.sign({
user_id: user_id
}, secret, { //密钥
expiresIn: '24h' //过时时间设置为24h。那么decode这个token的时候获得的过时时间为:建立token的时间+设置的值
});
return token;
};
复制代码
return 出来的 token 相似eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYWRtaW4iLCJpYXQiOjE1MzQ2ODQwNzAsImV4cCI6MTUzNDc3MDQ3MH0.Y3kaglqW9Fpe1YxF_uF7zwTV224W4W97MArU0aI0JgM
。咱们仔细看这字符串,分为三段,分别被 "." 隔开。如今咱们分别对前两段进行base64解码以下:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ===> {"alg":"HS256","typ":"JWT"} 其中 alg是加密算法名字,typ是类型 eyJ1c2VyX2lkIjoiYWRtaW4iLCJpYXQiOjE1MzQ2ODQwNzAsImV4cCI6MTUzNDc3MDQ3MH0 ===> {"user_id":"admin","iat":1534684070,"exp":1534770470} 其中 name是咱们储存的内容,iat建立的时间戳,exp到期时间戳。 Y3kaglqW9Fpe1YxF_uF7zwTV224W4W97MArU0aI0JgM ===> 最后一段是由前面两段字符串,HS256加密后获得。因此前面的任何一个字段修改,都会致使加密后的字符串不匹配。 复制代码
当咱们根据用户的id建立获取到token以后,咱们须要把token返回到客户端,客户端对其在本地(localStorage)保存, 客户端以后的每一次请求都要带上token,在请求头里加入Authorization,并加上token,服务端进行验证token的有效性。那么咱们如何验证token的有效性呢? 因此咱们须要checkToken这个中间件来检测token的有效性。
/**
* checkToken
*/
const jwt = require('jsonwebtoken');
const secret = '我是密钥'
module.exports = async ( req, res, next ) => {
const authorization = req.get('Authorization');
if (!authorization) {
res.status(401).end(); //接口须要认证可是有没带上token,返回401未受权状态码
return
}
const token = authorization.split(' ')[1];
try {
let tokenContent = await jwt.verify(token, secret); //若是token过时或验证失败,将抛出错误
next(); //执行下一个中间件
} catch (err) {
console.log(err)
res.status(401).end(); //token过时或者验证失败返回401状态码
}
}
复制代码
那么如今我们只要在须要用户认证的接口上,在操做数据以前,加上checkToken中间件便可,以下调用:
//更新用户信息 router.post('/updateUserInfo', checkToken, User.updateUserInfo) //若是checkToken检测不成功,它便返回401状态码,不会对User.updateUserInfo作任何操做, 只有检测token成功,才能处理User.updateUserInfo 复制代码
咱们如何保证每次请求都能在请求头里加入Authorization,并加上token,这就要用到Axios的请求拦截,而且也用到了它的响应拦截,由于在服务端返回401状态码以后应要执行登出操做,清楚本地token的存储,具体代码以下:
//request拦截器 instance.interceptors.request.use( config => { //每次发送请求以前检测本地是否存有token,都要放在请求头发送给服务器 if(localStorage.getItem('token')){ if (config.url.indexOf('upload-z0.qiniup.com/putb64') > -1){ config.headers.Authorization = config.headers['UpToken']; //加上七牛云上传token } else { config.headers.Authorization = `token ${localStorage.getItem('token')}`.replace(/(^\")|(\"$)/g, ''); //加上系统接口token } } console.log('config',config) return config; }, err => { console.log('err',err) return Promise.reject(err); } ); //response拦截器 instance.interceptors.response.use( response => { return response; }, error => { //默认除了2XX以外的都是错误的,就会走这里 if(error.response){ switch(error.response.status){ case 401: console.log(error.response) store.dispatch('ADMIN_LOGINOUT'); //多是token过时,清除它 router.replace({ //跳转到登陆页面 path: '/login', query: { redirect: '/dashboard' } // 将跳转的路由path做为参数,登陆成功后跳转到该路由 }); } } return Promise.reject(error.response); } ); 复制代码
其中的if else 是由于本系统的图片,音视频是放在七牛云,上传须要七牛云上传base64图片的时候token是放在请求头的,正常的图片上传不是放在请求头,因此这边对token作了区分,如何接入七牛云也会在下面模块介绍到。
本系统的图片,音视频是放在七牛云,因此须要接入七牛云。七牛云分了两种状况,正常图片和音视频的上传和base64图片的上传,由于七牛云在对他们二者上传的Content-Type
和domain(域)
有所不一样,正常图片和音视频的Content-Type是headers: {'Content-Type':'multipart/form-data'}
domain是domain='https://upload-z0.qiniup.com'
,而base64图片的上传则是headers:{'Content-Type':'application/octet-stream'}
domain是domain='https://upload-z0.qiniup.com/putb64/-1'
,因此他们请求的时候token放的地方不一样,base64就像上面所说的放在请求头Authorization
中,而正常的放在form-data
中。在服务端经过接口请求来获取七牛云上传token,客户端获取到七牛云token,经过不一样方案将token带上。
headers:{'Content-Type':'application/octet-stream'}
和 domain='https://upload-z0.qiniup.com/putb64/-1'
,token放在请求头Authorization
中。headers: {'Content-Type':'multipart/form-data'}
和domain='https://upload-z0.qiniup.com'
,token 放在 form-data
中。服务端经过qiniu
这个模块进行建立token,服务端代码以下:
/** * 构建一个七牛云上传凭证类 * @class QN */ const qiniu = require('qiniu') //导入qiniu模块 const config = require('../config') class QN { /** * Creates an instance of qn. * @param {string} accessKey -七牛云AK * @param {string} secretKey -七牛云SK * @param {string} bucket -七牛云空间名称 * @param {string} origin -七牛云默认外链域名,(可选参数) */ constructor (accessKey, secretKey, bucket, origin) { this.ak = accessKey this.sk = secretKey this.bucket = bucket this.origin = origin } /** * 获取七牛云文件上传凭证 * @param {number} time - 七牛云凭证过时时间,以秒为单位,若是为空,默认为7200,有效时间为2小时 */ upToken (time) { const mac = new qiniu.auth.digest.Mac(this.ak, this.sk) const options = { scope: this.bucket, expires: time || 7200 } const putPolicy = new qiniu.rs.PutPolicy(options) const uploadToken = putPolicy.uploadToken(mac) return uploadToken } } exports.QN = QN; exports.upToken = () => { return new QN(config.qiniu.accessKey, config.qiniu.secretKey, config.qiniu.bucket, config.qiniu.origin).upToken() //每次调用都建立一个token } 复制代码
//获取七牛云token接口 const {upToken} = require('../utils/qiniu') app.get('/api/uploadToken', (req, res, next) => { const token = upToken() res.send({ status: 1, message: '上传凭证获取成功', upToken: token, }) }) 复制代码
因为正常图片和音视频的上传和base64图片的上传,由于七牛云在对他们二者上传的Content-Type
和domain(域)
有所不一样,因此的token请求存放的位置有所不一样,所以要区分,客户端调用上传代码以下:
//根据获取到的上传凭证uploadToken上传文件到指定域 //正常图片和音视频的上传 uploadFile(formdata, domain='https://upload-z0.qiniup.com',config={headers:{'Content-Type':'multipart/form-data'}}){ console.log(domain) console.log(formdata) return instance.post(domain, formdata, config) }, //base64图片的上传 //根据获取到的上传凭证uploadToken上传base64到指定域 uploadBase64File(base64, token, domain = 'https://upload-z0.qiniup.com/putb64/-1', config = { headers: { 'Content-Type': 'application/octet-stream', }, }){ const pic = base64.split(',')[1]; config.headers['UpToken'] = `UpToken ${token}` return instance.post(domain, pic, config) }, 复制代码
function upload(Vue, data, callbackSuccess, callbackFail) { //获取上传token以后处理 Vue.prototype.axios.getUploadToken().then(res => { if (typeof data === 'string'){ //若是是base64 const token = res.data.upToken Vue.prototype.axios.uploadBase64File(data, token).then(res => { if (res.status === 200){ callbackSuccess && callbackSuccess({ data: res.data, result_url: `http://p89inamdb.bkt.clouddn.com/${res.data.key}` }) } }).catch((error) => { callbackFail && callbackFail({ error }) }) } else if (data instanceof FormData){ //若是是FormData data.append('token', res.data.upToken) data.append('key', `moment${Date.now()}${Math.floor(Math.random() * 100)}`) Vue.prototype.axios.uploadFile(data).then(res => { if (res.status === 200){ callbackSuccess && callbackSuccess({ data: res.data, result_url: `http://p89inamdb.bkt.clouddn.com/${res.data.key}` }) } }).catch((error) => { callbackFail && callbackFail({ error }) }) } else { const formdata = new FormData() //若是不是formData 就建立formData formdata.append('token', res.data.upToken) formdata.append('file', data.file || data) formdata.append('key', `moment${Date.now()}${Math.floor(Math.random() * 100)}.${data.file.type.split('/')[1]}`) // 获取到凭证以后再将文件上传到七牛云空间 console.log('formdata',formdata) Vue.prototype.axios.uploadFile(formdata).then(res => { console.log('res',res) if (res.status === 200){ callbackSuccess && callbackSuccess({ data: res.data, result_url: `http://p89inamdb.bkt.clouddn.com/${res.data.key}` //返回的图片连接 }) } }).catch((error) => { console.log(error) callbackFail && callbackFail({ error }) }) } }) } export default upload 复制代码
系统的后台管理面向的是合做做者和管理员,涉及到两种角色,故此要作权限管理。不一样的权限对应着不一样的路由,同时侧边栏的菜单也需根据不一样的权限,异步生成,不一样于以往的服务端直接返回路由表,由前端动态生成,接下来介绍下登陆和权限验证的思路:
登陆:当用户填写完帐号和密码后向服务端验证是否正确,验证经过以后,服务端会返回一个token,拿到token以后前端会根据token再去拉取一个getAdminInfo的接口来获取用户的详细信息(如用户权限,用户名等等信息)。
权限验证:经过token获取用户对应的role,动态根据用户的role算出其对应有权限的路由,经过vue-router的beforeEach进行全局前置守卫再经过router.addRoutes动态挂载这些路由。
代码有点多,这边就直接放流程图哈~~
最近正好也在公司作中后台项目,公司的中后台项目的这边是由服务端生成路由表,前端进行直接渲染,毕竟公司的一整套业务比较成熟。可是咱们会在想能不能由前端维护路由表,这样不用到时候项目迭代,前端每增长页面都要让服务端兄弟配一下路由和权限,固然前提多是项目比较小的时候。
帐号模块是业务中最为基础的模块,承担着整个系统全部的帐号相关的功能。系统实现了用户注册、用户登陆、密码修改、找回密码功能。
系统的帐号模块使用了邮件服务,针对普通用户的注册采用了邮件服务来发送验证码,以及密码的修改等操做都采用了邮件服务。在node.js中主要采用了Nodemailer,Nodemailer是一个简单易用的Node.js邮件发送组件,它的使用能够摸我摸我摸我,经过此模块进行邮件的发送。大家可能会问,为何不用短信服务呢?哈~由于短信服务要钱,哈哈哈
/* * email 邮件模块 */ const nodemailer = require('nodemailer'); const smtpTransport = require('nodemailer-smtp-transport'); const config = require('../config') const transporter = nodemailer.createTransport(smtpTransport({ host: 'smtp.qq.com', secure: true, port: 465, // SMTP 端口 auth: { user: config.email.account, pass: config.email.password //这里密码不是qq密码,是你设置的smtp受权码 } })); let clientIsValid = false; const verifyClient = () => { transporter.verify((error, success) => { if (error) { clientIsValid = false; console.warn('邮件客户端初始化链接失败,将在一小时后重试'); setTimeout(verifyClient, 1000 * 60 * 60); } else { clientIsValid = true; console.log('邮件客户端初始化链接成功,随时可发送邮件'); } }); }; verifyClient(); const sendMail = mailOptions => { if (!clientIsValid) { console.warn('因为未初始化成功,邮件客户端发送被拒绝'); return false; } mailOptions.from = '"ShineTomorrow" <admin@momentin.cn>' transporter.sendMail(mailOptions, (error, info) => { if (error) return console.warn('邮件发送失败', error); console.log('邮件发送成功', info.messageId, info.response); }); }; exports.sendMail = sendMail; 复制代码
帐号的注册先是填写email,填写好邮箱以后会经过Nodemailer发送一封含有有效期的验证码邮件,以后填写验证码、昵称和密码便可完成注册,而且为了安全考虑,对密码采用了安全哈希算法(Secure Hash Algorithm)进行加密。帐号的登陆以帐号或者邮箱号加上密码进行登陆,而且采用上文所说的JSON Web Token(JWT)身份认证机制,从而实现用户和用户登陆状态数据的对应。
当用户被人关注、评论被他人回复和点赞等一些社交性的操做的时候,在数据存储完成后,服务端应须要及时向用户推送消息来提醒用户。消息推送模块采用了Socket.io
来实现,socket.io封装了websocket,不支持websocket的状况还提供了降级AJAX轮询,功能完备,设计优雅,是开发实时双向通信的不二手段。
经过 socket.io,用户每打开一个页面,这个页面都会和服务端创建一个链接。在服务端能够经过链接的socket的id属性来匹配到一个创建链接的页面。因此用户的ID和socket的id,是一对多的关系,即一个用户可能在登陆后打开多个页面。而socket.io没有提供从服务端向某个用户单独发送消息的功能,更没有提供向某个用户打开的全部页面推送消息的功能。可是socket.io提供了room的概念,即群组。在创建websocket时,客户端能够选择加入某个room,若是这个room没有存在则自动新建一个,不然直接加入,服务端能够向某个room中的全部客户端推送消息。
根据这个特性,设计将用户的ID做为room的名字,当某个用户打开页面创建链接时,会选择加入以本身用户ID为名字的room。这样,在用户ID为名字的 room中,加入的都是用户本身打开的页面创建的链接。从而向某个用户推送消息,能够直接经过向以此用户的ID为名字的room发送消息,这样就会推送到用户打开的全部页面。
有了想法后咱们就开始鲁吧~,在服务端中socket.io
在客户端中使用vue-socket.io
, 服务端代码以下:
/* * app.js中 */ const server = require('http').createServer(app); const io = require('socket.io')(server); global.io = io; //全局设上io值, 由于在其余模块要用到 io.on('connection', function (socket) { // setTimeout(()=>{ // socket.emit('nodeEvent', { hello: 'world' }); // }, 5000) socket.on('login_success', (data) => { //接受客户端触发的login_success事件 //使用user_id做为房间号 socket.join(data.user_id); console.log('login_success',data); }); }); io.on('disconnect', function (socket) { socket.emit('user disconnected'); }); server.listen(config.port, () => { console.log(`The server is running at http://localhost:${config.port}`); }); 复制代码
/* * 某业务模块 */ //例如某文章增长评论 io.in(newMusicArticle.author.user_id._id).emit('receive_message', newMessage); //实时通知客户端receive_message事件 sendMail({ //发送邮件 to: newMusicArticle.author.user_id.email, subject: `Moment | 你有未读消息哦~`, text: `啦啦啦,我是卖报的小行家~~ 🤔`, html: emailTemplate.comment(sender, newMusicArticle, content, !!req.body.reply_to_id) }) 复制代码
客服端代码:
<script> export default { name: 'App', data () { return { } }, sockets:{ connect(){ }, receive_message(val){ //接受服务端触发的事件,进行客户端实时更新数据 if (val){ console.log('服务端实时通讯', val) this.$notify(val.content) console.log('this method was fired by the socket server. eg: io.emit("customEmit", data)') } } }, mixins: [mixin], mounted(){ if (!!JSON.parse(window.localStorage.getItem('user_info'))){ this.$socket.emit('login_success', { //通知服务端login_success 事件, 传入id user_id: JSON.parse(window.localStorage.getItem('user_info'))._id }) } }, } </script> 复制代码
评论模块是为了移动端WebApp下的文章下为用户提供关于评论的一些操做。系统实现了对文章的评论,评论的点赞功能,热门评论置顶以及评论的回复功能。在评论方面存在着各类各样的安全性问题,好比XSS攻击(Cross Site Scripting,跨站脚本攻击)以及敏感词等问题。预防XSS攻击使用了xss
模块, 敏感词过滤使用text-censor
模块。
在开发的时候常常会遇到这个问题,接口数据问题。有时候服务端返回的数据并非咱们想要的数据,前端要对数据进行再一步的处理。
例如服务端返回的某个字段为null或者服务端返回的数据结构太深,前端须要不断去判断数据结构是否真的返回了正确的东西,而不是个null 或者undefined~
咱们前端都要这么去处理过滤:
<div class="author"> 文 / {{(musicArticleInfo.author && musicArticleInfo.author.user_id) ? musicArticleInfo.author.user_id.username : '我叫这个名字'}} </div> 复制代码
这就引出了一个思考:
对数据的进一步封装处理,必然渲染性能方面会存在问题,并且咱们要时刻担忧数据返回的问题。若是应用到公司的业务,咱们应该如何处理呢 ?
首屏渲染问题一直是单页应用的痛点,那么除了经常使用的性能优化,咱们还有什么方法优化的吗 ? 这个项目虽然面向的是移动端用户,可能不存在SEO问题,若是作成pc端的话,像文章这类的应用,SEO都是必须品。
对于上面提出的问题,node的出现让咱们看到了解决方案,那就常说的Node中间层,固然本项目中是不存在Node中间层,而是直接做为后端语言处理数据库。
因为大部分的公司后端要么是php要么是java,通常不把node直接做为后端语言,若是有使用到node,通常是做为一个中间层的形式存在。
对于第一个问题的解决:咱们能够在中间层作接口转发,在转发的过程当中作数据处理。而不用担忧数据返回的问题。
对于第二个问题的解决:有了Node中间层的话,那么咱们能够把首屏渲染的任务交给nodejs去作,次屏的渲染依然走以前的浏览器渲染。
有Node中间层的话,新的架构以下:
先后端的职能:
已经毕业一段时间了,写文章是为了回顾。本人水平通常,见谅见谅。这个产品的实现,一我的扛,在其中充当了各类角色,要有一点点产品思惟,要有一点点设计的想法,要会数据库设计,要会后端开发,挺繁琐的。最难的点我的感受仍是数据库设计,数据库要一开始就要设计的很完整,否则到后面的添添补补,就会很乱很乱,固然这个基础是产品要很是清晰,刚开始本身心中对产品多是个模糊的定义,想一想差很少是那样,因而乎就开始搞~~致使于后面数据库设计的不是很满意。因为时间关系,如今的产品中有些小模块还没完成,可是大部分的功能结构已经完成,算是个成型的产品,固然是一个没有通过测试的产品哈哈哈哈,要是有测试的话,那就哈哈哈哈你懂得 ~~~。
前路漫漫,吾将上下而求索~
完
谢谢~~