文章目录
摘要Abstract引言系统开发理论基础开发语言简介HTML5JavaScriptLessPHPRPythonGo框架简介Vue.jsThinkPHPFlaskPlumberCordova系统分析需求分析服务端系统架构设计服务端功能结构客户端功能结构功能模块服务端功能模块用户模块作品模块评论模块消息模块直播模块客户端功能模块用户模块用户管理模块作品模块消息模块直播模块系统数据库设计MySQL概述系统实体关系E-R图数据库逻辑模型触发器存储过程事件系统实现数据爬取爬虫数据库PIXIV爬取扫描添加用户和及其作品扫描用户之间关系数据挖掘数据读取KNN最近邻推荐算法Apriori关联分析消息投递API接口投递消息事件处理客户端实现页面初始化用户页面的实现使用账号密码登录使用短信登录注册修改密码修改手机号上传头像注销账号用户主页最新页面的实现头部固定块作品列表详细页面新作品的页面实现消息页面的实现消息列表聊天页面直播页面的实现构建Android原生应用总结参考文献:附 录致 谢摘要
随着计算机和网络革命性地发展,越来越多的画师使用最新科技工具管理自己的画作。本毕业设计旨在借助先进的计算机、快捷的网络和便利的智能手机来帮助画师随时随地分享自己的画作。
本画作交流平台以HTML5、JavaScript、PHP、Less、Python、R、JAVA、Go作为开发语言。本平台前后端分离,客户端使用Vue.js框架的单页面富应用,采用node.js作为开发平台,webpack为静态模块打包器,VUX为移动端UI组件库,Less为CSS预处理语言,Cordova为移动框架。客户端包括用户模块、用户管理模块、作品列表模块、消息模块、直播模块。服务端使用ThinkPHP框架,搭建在京东云服务上,利用腾讯云提供短信服务,百度云提供图片审核服务和自然语言处理服务,阿里云提供视频直播服务和CDN加速服务,采用迅搜用于全文检索,使用Python的Flask轻量级Web应用框架用于推送订阅消息,采用R语言编写推荐画作的数据挖掘算法,使用SurgeMQ库来提供MQTT服务器,用于直播弹幕的收发。服务端包括用户模块、作品模块、评论模块、消息模块、直播模块。
关键词:画作交流平台; HTML5; JavaScript; Vue.js; ThinkPHP
Abstract
With the development and revolution of computer and network, the number of artists who use the last technique machine tool to manage their artwork are grow
dramatically. The aim of this graduation project is to help artists share the painting with others anytime and anywhere with advanced computer science, convince Internet and smartphone.
This artwork communication platform uses HTML5, JavaScript, PHP, Less, Python, R, JAVA, Go as the development languages. The front and back ends of the
platform are separated. The client uses the single-page rich application of the Vue.js framework, using node.js as the development platform, webpack as the
module bundler, VUX as the mobile UI component library, and Less as the CSS preprocessing language, Cordova is mobile development framework. The client
includes user module, user management module, work list module, message module and live broadcast module. The server uses the ThinkPHP framework to build on the Jingdong cloud server and uses Tencent Cloud to provide SMS services. Baidu Cloud provides image review services and natural language processing services. Alibaba Cloud provides live video services and CDN acceleration services, using Xun search for full-text search. The Python-based Flask lightweight web application framework is used to push subscription messages, the R-language is used to write data mining algorithms for recommended paintings, and the SurgeMQ library is used to provide an MQTT server for sending and receiving live bullet screen. The server side includes user module, work module, comment module, message module and live broadcast module.
Key words:artwork communication platform; HTML5; JavaScript; Vue.js; ThinkPHP
引言
目前,随着互联网的日益进步与革命性的发展,互联网已经成为人类生活、工作和学习中不可或缺的存在。它颠覆了传统的信息传播方式,无论是形式还是内容、无论是生产方式还是消费方式,都给人类带来了性的机遇与挑战。人类已经不能离开网络,世界已经进入信息化的时代。
智能手机的问世,方便了许多人的日常生活。智能手机就像一个个人电脑一样,具有独立的操作系统,可以让用户自行安装软件、游戏等第三方服务商提供的程序,通过此类程序来不断地对手机功能进行扩充,并可以通过移动网络实现无线网络接入。智能手机在现代的生活中越来越重要。
随着10月由W3C发布为HTML5正式推荐标准,这门语言逐渐走向规划化道路。HTML5最大的优势就是可以将它应用到多个移动端平台,实现跨平台开发,即开发一次,多次使用。因此,其良好的跨平台兼容性备受关注,并且成为了移动端平台开发技术中最重要的一员。
随着社交范围的扩展,画师们对作品交流的需求也水涨船高。asadw11他们已经不再满足于先前过时的线下实体画展。Adaqwdaw415在新的互联网时代,画师需要一个可以随时分享展示自己完成的绘画作品,以及收藏和下载自己喜欢绘画作品,或者分享给好友的线上平台。
针对这些急迫的需求,本系统是一款基于HTML5的跨平台线上画师作品交流系统,采用先进、快捷的技术,来满足画师等需求。画师可以随时随地地发表作品,可以查看别的画师的画作,收藏自己喜欢的作品。此外,还能参与作品评论,获取最新评论,以及回复别人的评论。面向多端平台,具有及时更新和便利性。
本画作交流平台以HTML5、JavaScript、PHP、Less、Python、R、JAVA、Go作为开发语言。本平台前后端分离,客户端为使用Vue.js框架的单页面富应用(SPA)。本地的开发使用Node.js平台,使用cnpm淘宝包管理代替稳定性差的npm包管理,webpack为打包器,label转换ES6语句为ES5,UglifyJs对JS压缩混淆,使用eslint保证JS代码质量,VUX为移动端UI组件库,Less为CSS预处理语言,Cordova为移动开发框架,并把前端打包为原生应用,使用基于IJKplayer开发的GSYVideoPlayer作为安卓APP端的直播弹幕播放控件。客户端包括用户模块、用户管理模块、作品列表模块、消息模块、直播模块。后端使用ThinkPHP框架提供高效便捷的数据逻辑处理,PHP-FPM作为PHP
FastCGI管理器,MySQL负责数据存储,Redis作为数据缓存数据库,Nginx (engine
x)提供HTTP和反向代理服务,Flask作为python的Web框架,提供订阅的消息投递服务。R语言作为数据挖掘语言,采用KNN最近邻协同推荐算法。Plumber作为R语言的API服务器框架。SurgeMQ为Go语言的库,用来提供高效的MQTT服务。直播中的弹幕采用MQTT消息协议收发。前后端采用AJAX传递JSON格式的数据。服务端搭建在京东云服务器上,利用腾讯云提供短信服务,百度云提供图片审核服务和自然语言处理服务,阿里云提供视频直播服务和CDN加速服务,采用迅搜用于全文检索,基于Python的Flask轻量级Web应用框架用于推送订阅消息,基于Plumber的R语言服务器框架用于编写推荐画作的数据挖掘算法。服务端包括用户模块、作品模块、评论模块、消息模块、直播模块。
本文接下来将详细介绍本画作交流平台的开发理论基础、系统设计与实现等。
系统开发理论基础
开发语言简介
HTML5
HTML全称为Hyper Text Markup
Language。它的中文名字是“超文本标记语言”,通俗地说就是为了“网页和其他可在网页浏览器看的信息”设计的一种标记语言。这个语言通过提供浏览器可以识别的标签来实现网页的渲染。因为网页的展示形式不仅仅有文本,还包括图片、动画等,所以称为超文本。
HTML5数HTML的最新修订版本,于10月完成其标准的定制。HTML5是唯一的一个适配PC、Mac、iPhone、iPad、Android、Windows
Phone等主流平台的跨平台的计算机语言。一次开发,即可部署到不同的移动端应用设备。
JavaScript
JavaScript,是一种高级和解释执行的高级编程语言。JS是一门基于原型、函数先行的语言,是一门多范式的语言。JS支持面向对象编程,命令式编程,以及函数式编程。JS已经由ECMA(欧洲计算机制造商协会)通过ECMAScript实现其标准化。这个语言被世界上的绝大多数网站所使用,也同时被许多世界主流浏览器支持。
Less
Less是由Alexis Sellier设计而成的一种的动态层叠样式表语言。
Less是开源的。从语法方面来看,Less和CSS较为接近。其中一个合法的CSS代码段的本身也是一段合法的Less代码段。该语言与CSS不同,其拥有变量、嵌套、混合、操作符、函数等编程所需抽象机制。
PHP
PHP是一种被广泛应用的开源脚本语言。适用于 Web
开发,并支持嵌入到HTML中去。PHP的语法利用了 C、Java 和
Perl,极其容易学习。该语言的目标是允许web开发人员快速编写并且动态生成的 web
页面。但事实上 PHP 的用途远不只于此。
R
R拥有则两个不同的含义,既表示一种专门用于数据分析建模及绘图的语言,又表示的是一个拥有者统计分析强大作图功能的软件系统。R语言是由新西兰奥克兰大学的Ross
Ihaka和Robert
Gentleman共同创造的。R软件是一个免费的自由软件,包括UNIX、Linux、MacOS和Windows等几个版本,可以免费下载R语言的安装程序、外置程序和文档。
Python
Python是一种广泛使用的高级编程语言。这门语言是由吉多·范罗苏姆创造。可以视之为一种改良版本的LISP。作为一种解释型语言,该语言与其他语言相比,设计哲学强调代码的可读性和简洁的语法。相比于其他传统的编程语言,Python让开发者能够用简洁明了的代码来表达开发者自己的想法。不管是小型还是大型程序,该语言都试图让程序的结构清晰明了。
Go
Go(又称 Golang)是 Google 的 Robert Griesemer,Rob Pike 及 Ken Thompson
开发的一种静态强类型、编译型语言。Go 语言语法与 C
相近,但功能上有:内存安全,GC(垃圾回收),结构形态及 CSP-style
并发计算。
框架简介
Vue.js
Vue.js是一款用于创建用户界面的源码开发的JS框架,也是一个创建简单页面应用的Web应用框架。其开发目的是为了更好地组织和简化Web开发的流程。Vue所所关注的是MVC模式的视图层,预测同时它也能方便地更新数据,并且通过组件内部特定的方法来实现视图和模型之间的交互。
ThinkPHP
ThinkPHP是一个免费开源、快速、简单的面向对象的轻量级PHP开发框架。该框架从诞生以来就一直秉承简洁实用的设计原则。该框架在保持卓越的性能和简约的代码的同时,也十分注重易用性。
Flask
Flask是一款用Python编写而成的轻量级Web框架。Flask使用简单的核心,用扩展的方式增加其他功能。
Flask尽管没有默认使用的数据库、窗体验证工具,但是该框架保留了扩增的弹性。开发者可以用Flask-extension加入这些功能。
Plumber
R编程语言近年来已逐渐地成为最主要的数据分析和可视化编程语言之一。与此同时Web服务已成为允许各种系统彼此交互的通用语言。plumber的R包能使用户将现有R语言代码服务提供给其他网络上的应用。
Cordova
Apache
Cordova是一个开源的移动开发框架。允许你用标准的web技术-HTML5,CSS3和JavaScript做跨平台开发。
应用在每个平台的具体执行被封装了起来,并依靠符合标准的API绑定去访问每个设备的功能,比如说:传感器、数据、网络状态等。
系统分析
需求分析
通过对画师的走访、分析以及问卷调查,要求本系统具有以下功能:
客户端的目标用户是画师,要符合画师群体的使用风格。
用户可以使用账号密码登录、手机验证登录、注册、找回密码,注销账号。
用户可以查看别的用户的首页和热门作品。
用户可以和别的用户私信聊天。
用户可以关注别的用户的。及时接收对方新作品通知。
用户可以修改自己的手机号、密码。
用户可以修改自己的昵称、上传头像。
用户可以上传画作、删除画作来管理他们自己的画作。
用户上传画作的时候,系统支持自动补全待输入的标签,转换为候选标签提供给用户选择。
用户可以收藏或者取消他们喜欢的画作。
用户可以及时地获取最新画作。
用户可以搜索画作并对搜索结果排序。
用户可以获取可能喜欢画作推荐。
用户可以查看评论、发表自己的评论。只能删除自己评论,或者自己画作下面的评论。
用户可以获取评论被删除或者被别人回复。
用户可以观看或者发布直播。
用户可以观看直播的时候可以查看其它用户发的弹幕,自己也可以参与弹幕的发送,并且能与主播的互动。
系统必须要易维护,以及操作简单,UI界面清晰。
系统务必一定要防止SQL注入、XSS跨域攻击、API攻击。
系统可以全天候24H运行、安全可靠。
客户端应该混淆源码,防止逆向工程。
保证所有最新主流浏览器都可以正常渲染显示。
App端需保证所有主流的安卓手机都能安装运行。
服务端系统架构设计
根据对整个服务端系统需求的详细分析,得出如下图 3‑1所示的服务端系统架构设计图。
图 3-1 ‑ 服务端系统架构设计图
服务端功能结构
根据对服务端需求的详细分析,得出如下图 3‑2所示的服务端功能结构图。
图 3-2 ‑ 服务端功能结构图
客户端功能结构
根据对用户需求的详细分析,如下图 3‑3所示的客户端功能结构图。
图 3-3 ‑ 客户端功能结构图
功能模块
服务端功能模块
用户模块
获取用户信息:可选传递用户ID。如果为空则查询已经登录用户自己信息。验证账号名是否存在,如果登录成功,返回账号信息和前几项的热门作品,失败返回错误原因和标志。
登录:分账号密码登录、短信登录方式。接收账号名或者手机号、SHA3加密的密码或者短信验证码、登录方式(短信登录或者账号密码登录)三个参数。验证账号名是否存在,密码或者短信验证码是否与其匹配。如果登录成功,返回包含账号信息的JSON,失败返回错误标志和错误内容。
获取加密盐:返回登录注册加密密码的盐。
计算密码强度:传入密码字串,返回密码强度数值[0,4]。
注册:需要提交用户名、昵称、手机号、SHA3加密的密码和手机验证码,这些参数。验证用户名、昵称、手机号是否唯一且符合规定的格式大小,并且不包含敏感词。验证短信验证码正确性。注册成功返回注册成功标志,否则返回错误标记以及错误信息。
验证注册信息:需要提交需要验证的字段和内容。验证正确返回成功标志,否则返回错误标记以及错误信息。
注销:删除当前账号相关的登录信息缓存。
发送验证码:发送验证码到指定手机。需要提交手机号和验证方法二个参数。返回发送状态结果。限制每个IP和账号只能在120s发送一次,一天可以发送5次。来自腾讯云服务方面限制:对同一个手机号而言,30秒内发送短信条数不能操作
1条, 1小时内发送短信条数不可以 5条,
1个自然日内发送短信条数也不能多于10条。否则拒绝发送。
验证短信验证码:为修改密码前置操作。必须已经登录,且要提交短信验证码,如果通过验证存入session里面1分钟,并且返回正确标志,否则返回错误标志和失败原因。
修改密码:需要提交SHA3加密的密码,读取session,判断之前是否通过过短信验证。如果验证码有效,密码符合规范。则修改密码,并且退出登录。返回成功。否则返回失败和失败原因。
验证用户密码:为修改手机号前置操作。必须已经登录,且要提交SHA3码加密的密码,如果通过验证存入session里面1分钟,并且返回正确标志,否则返回错误标志和失败原因。
修改手机号:需要输入手机号和手机验证码。读取session,判断之前是否通过旧密码验证。成功则修改手机号,返回成功标志并且退出登录。否则返回失败标志和失败原因。
修改昵称:需要输入新的昵称。成功返回成功标志并且退出登录。否则返回失败标志和失败原因。
修改头像:需要发送头像图片作为参数。图片经过图像审核,过滤色情暴力的图片。成功返回成功的标志。否则返回失败标志和失败原因。
关注:必须传入关注用户ID。如果已经关注则取消关注,否则添加关注。成功返回成功标志,否则返回失败标志和失败原因。
作品模块
上传作品:需要传入作品标题、缩略图、图片、标签、详细介绍参数。缩略图需要通过图片审核,以便过滤色情暴力图片。成功返回成功标志。并且对所有关注该用户的人发送新作品消息。否则返回失败标志和失败原因。
标签补全:传入标签。返回候选标签列表。
删除作品:需要发送作品ID到服务器。ID存在且鉴定是否有删除作品的权限,成功则删除图片,并且返回成功标志。否则返回失败标志和失败原因。
获取作品列表:需要发送查询范围(最新、收藏、关注列表、个人)、限制参数(用于翻页参数使用,可以为空)、查询关键字(如果不是搜索的话则不需要)、排序字段(按照默认排序、标题、更新时间、收藏时间(查询访问范围必须为收藏)、收藏人数)、排序方式(升序或者降序,默认数值根据排序方式不同)、搜索字段(全部、标题、标签、用户,默认为全部)。
当排序字段为默认的时候,限制关键数为页码,默认第0页。按照热度和匹配度返回比限制关键数所指定页数的作品。
当排序字段为标题时候,限制关键字填入已经获取集合中字典序最大(或者最小)(根据排序方式决定),排序方式默认数值为升序。返回的为比限制关键字的标题的字典序大(或者小)的作品。
当排序字段为更新时间时候,限制关键字填入已经获取集合中更新时间最小(或者最大)(根据排序方式决定),排序方式默认数值为降序。返回的为比限制关键字的更新时间的更新时间小(或者大)的作品。
当排序字段为收藏时间时候(查询访问范围必须为收藏),限制关键字填入已经获取集合中收藏时间最小(或者最大)(根据排序方式决定),排序方式默认数值为降序。返回的为比限制关键字的收藏时间的收藏时间跟小(或者更大)的作品。
当排序字段为收藏人数时候,限制关键字填入已经获取集合中收藏人数最小(或者最大)(根据排序方式决定),排序方式默认数值为降序。返回的为比限制关键字的收藏人数的更少(或者更多)的作品。
返回包含n项作品的列表。每个作品包含图片:ID、标题、缩略图、宽度、高度、缩略图高度、更新时间、收藏数、标签、作者ID、昵称、用户头像,失败返回失败标志和失败原因。
获取推荐:如果登录的话,返回包含n项作品的列表。每个作品包含图片:ID、标题、缩略图、宽度、高度、缩略图高度、更新时间、收藏数、标签、作者ID、昵称、用户头像,失败返回失败标志和失败原因。
获取详细信息:需要传入作品ID作为参数。验证作品ID是否存在。成功返回作品的标题、大图的地址、图片宽度、图片高度、标签、详细内容、收藏数、作者ID、昵称、头像。如果失败则返回失败标志和失败原因。
添加或取消收藏:需要传入作品ID作为参数。验证作品ID是否存在,且非作者自己作品。如果已经收藏,则取消收藏;如果未收藏则添加收藏。如果返回成功标志,否则返回失败标志和失败原因。
评论模块
获取评论:需要传入作品ID、最新时间(为空则以服务器时间为准)。验证作品ID是否存在。返回包含从最新时间向前数的n项作品的评论列表和回复评论列表。每个评论包含评论ID、内容、评论时间、回复路径、评论者ID、昵称头像。回复数组中包含回复的ID、内容、评论时间、回复路径、评论者ID、昵称头像。评论中的回复路径包含被删除的评论,但回复评论列表只包含未被删除的评论。成功返回成功标志,失败则返回失败标志和失败原因。
添加评论:需要传入作品ID、评论、回复评论(可选)作为参数。验证作品是否存在,评论是否包含敏感词汇。如果操作成功的话,返回成功标志,失败则返回失败标志和失败原因。
删除评论:需要传入评论ID。鉴定是否有删除该评论的权限。有则设置软删除评论并且返回成功标志,失败返回失败标志和失败原因。
消息模块
发送消息:需要传入接收ID、内容。如果是由用户发送过来的。内容需要通过内容审核,从session中获取用户ID作为发送者,类型为指定为pm。如果是系统内部调用,发送方为空。类型允许任意。成功返回成功标志,失败则返回失败标志和失败原因。
获取消息列表:可选传入消息类型(默认值为所有)、只显示未读标志(默认为假)、限制时间(为空则以服务器时间为准)、是否显示最新(默认为假)、指定发送者(默认不指定)。成功返回成功标志和包含n项消息列表。如果显示最新为真,则为比限制时间更新的作品,否则为比限制时间更早的作品。失败则返回失败标志和失败原因。
已读标记:需要传入消息id、消息类型(用户或者系统消息)。可选为设置读取的状态(默认设置为已读)。鉴权通过标记为已读,返回成功状态。失败则返回失败标志和失败原因。
删除:需要传入消息id。鉴权通过后删除消息,返回成功状态。失败则返回失败标志和失败原因。
直播模块
获取推流地址:传入直播流唯一名称,返回推流唯一地址,有效期为2小时。
获取播流地址:传入直播流唯一名称,获取到播流地址数组。包含rtmp、flv、hls三种协议地址,地址有效期2小时。
收发弹幕:收发MQTT格式的消息。
客户端功能模块
用户模块
登录:默认为账号密码登录。可以切换短信登录方式。输入手机号,跳转进入短信验证码界面。如果正确返回用户首页,错误则提示。
注册:输入用户名、昵称、密码、再次确认密码、手机号、手机验证码。每输入完一条都要求验证。全部不为空且验证通过,提交注册表单。
注销:弹出登出确认对话框。用户再次确认之后注销用户的账号。
用户首页:显示用户信息和热门作品。可以和当前用户私信聊天或者添加关注。
用户管理模块
修改密码:输入手机号和验证码,验证通过以后进入修改密码页面。输入新密码和再次确认密码,退出重新登录。
修改手机号:输入原始密码。验证通过以后输入手机号。验证通过以后,退出登录。
修改昵称:输入新的昵称,通过验证以后更新用户昵称。
上传头像:选择需要上传的图片,压缩以后上传到服务端。如果通过,更新用户头像。
作品模块
作品列表:(1)获取作品:打开网站或者应用,默认显示最新作品。用户可以切换只显示自己的作品、收藏作品、所有作品。可以选择列表中的排序方式:按照匹配度(如果当前为搜索结果的话,则拥有这个选项),更新时间,标题、收藏数和收藏时间(如果当前范围为收藏夹的话,则拥有这个选项)。(2)显示作品:每张图片使用瀑布流布局。图片左上角显示序号,下方显示标题。图片下方显示作者头像和昵称。点击用户头像可以跳转到作者首页。(3)刷新作品:如果用户下拉列表,则尝试获取刚刚上传排序比当前列表靠前的作品,如果获取到了则显示并提示获取数目。(4)获取推荐:如果用户已经登录,则根据用户的偏好,智能地给用户推荐可能喜欢的作品。(5)刷新推荐:重新计算并获取用户可能喜欢的作品。(6)获取更多作品:如果用户滑到最底下时候,尝试获取比最后一张排序顺序跟后的作品。如果获取到空列表或者上一次获取的长度小于指定长度n,则显示已经到底,不在触发获取更多。(7)搜索:点击搜索按钮,在顶部弹出搜索窗内输入内容。点击搜索,在主页面显示搜索结果。其他与获取最新作品相同。(8)收藏:如果不是当前用户的作品,则显示收藏按钮。点击作品右下角的爱心,即可快速添加或者取消收藏。(9)显示详细信息:点击图片,跳转到详细信息页面。(10)回到顶部:如果用户向下滑动屏幕,则右边底部显示回到最顶按钮。(11)删除:如果是当前用户的作品,则显示删除按钮。点击作品右下角的垃圾桶,确认删除以后,删除作品。
详细页面:(1)显示详细信息:打开页面时,依次加载标题、图片、标签、作者昵称头像、作品详细介绍、加载评论。如果不是作者的作品则显示收藏标志和收藏数,否则显示删除按钮。(2)删除:同作品列表的删除效果一样。(3)收藏:同作品列表的收藏效果一样。(4)评论:显示评论:在加载详细页面的时候自动加载好。(5)显示评论:评论包含评论者的头像、昵称、友好显示的评论时间、用户回复的内容、用户自身评论和评论按钮。如果当前评论为用户发布、作品为该用户时,则显示删除按钮。(6)显示更多评论:点击加载更多评论,显示更多评论。(7)展开回复:当回复数量超过一定数目的时候,折叠回复。点击即可显示所有回复。(8)删除回复:点击删除按钮,再次确认删除,则删除该评论。(9)评论:可以在下方输入评论。点击表情按钮可以弹出表情选择框。点击小飞机即可发送评论。(10)回复评论:点击评论下面的回复按钮即可进入回复该评论模式。评论输入效果同上方回复评论一样。选中的评论及其回复的评论会搭在当前用户发布的评论上面。
上传作品:上传作品需要填写标题,图片,标签和详细内容。(1)选择图片:点击图片框,则弹出图片选择页面。可以从图库或者相机内选择任意格式但小于5M大小的图片。如果标题为空,则默认图片名为标题。(2)标签补全:当用户输入标签的时候,可以根据用户输入的字段,弹出候选标签下拉列表。(3)上传:图片经过本地压缩以后上传到服务端上。如果通过服务端验证,则显示上传成功。用户可以选择继续上传作品或者查看自己刚刚上传的作品。
消息模块
消息列表:(1)获取消息:当打开页面的时候会自动获取最新的消息。未读的消息右边会有显示小红点。用户消息会根据用户分组合并,右边的小红点还包含未读消息数。顶上可以切换列表是显示用户信息还是系统消息。导航上显示每种列表中未读消息数。(2)获取更多消息:如果消息列表滑到顶,则自动加载更早的消息。(3)刷新消息:下拉消息列表,则刷新获取最新消息。(4)查看消息:在用户消息列表中,点击消息可以进入聊天页面;在系统消息列表中,则直接显示系统的消息内容。点击其中内容则可以跳转到对应的图片,或者用户首页。(5)已读消息:向左滑动消息,则浮现已读消息按钮。在用户消息列表中,则已读对应用户所有的所有消息;在系统消息列表中,则已读这条消息。
聊天:(1)获取历史:滑动到最顶,下拉页面,则获取当前用户历史消息。(2)刷新消息:在最低上拉页面,则获取最新消息。(3)查看消息:进入聊天页面,则自动滑动到最底下。其内容和详细页面中评论一样。(4)发送消息:效果同详细页面中发送评论。
直播模块
观看直播:输入直播主的用户名,则可以进入直播间。如果浏览器允许的话,则会自动开始播放直播。点击播放按钮可以播放直播,点击暂停按钮可以暂停直播。点击全屏按钮可以全屏。可以调节音量。
弹幕收发:以直播间名字作为消息的主题,收到的弹幕及时渲染在直播视频上。
系统数据库设计
MySQL概述
MySQL是一款开源的关系数据库管理系统。在的时候,甲骨文公司(Oracle)收购昇阳微系统公司,因此MySQL成为Oracle旗下产品。
MySQL由于其高性能、低成本、高可靠性的优点,已经成为了现在最流行的数据库。MySQL被广泛地应用在互联网上的各种中小型网站中。随着MySQL的不断成熟和发展,它也逐渐用于更多大规模网站和应用。
关于数据库设计是整体系统开发中的核心技术。数据库位于系统的底层、读写最频繁,正确地设计存放数据才能保证数据的正确性、一致性和高效性。
系统实体关系E-R图
根据本平台的需求绘制出的本系统实体关系E-R图如下图 4‑1所示:
图 4-1 ‑ 系统实体关系E-R图
数据库逻辑模型
根据E-R图绘制编写出来的数据库逻辑模型如下表 4‑1所示。
表 4‑1 逻辑模型表
触发器
触发器用来保证数据完整性的一种方法。它与表的事件相关,其执行不是由程序调用,也不是手工调用,而是由数据触发。数据库的触发器逻辑设计如表
4‑2所示:
表 4‑2 触发器逻辑设计表
存储过程
存储过程是一组在大型数据库系统中为了完成特定功能的SQL语句集合。用户可以通过自定义的存储过程名字和参数来执行他们。存储过程逻辑设计如表
4‑3所示:
表 4‑3 存储过程逻辑设计表
事件
事件是用来执行定时任务的一组SQL,到达预订的时间就会自动触发。事件逻辑表设计如表
4‑4所示:
表 4‑4 事件逻辑设计表
系统实现
本系统服务器端包括数据挖掘服务器、消息投递服务器以及PHP服务器。其中,数据挖掘使用的数据前期需要事先爬取;PHP服务器主要服务于客户端各功能模块。
本系统客户端包括用户页面、最新页面、新作品页面、消息页面、直播页面等功能模块。
数据爬取
在做数据挖掘之前,需要大量的数据。本系统选择日本的虚拟社区PIXIV进行数据爬取。爬取主要分成四大部分:爬虫数据库、PIXIV爬取、扫描添加用户和及其作品、扫描用户之间关系。
爬虫数据库
由于需要爬取的数据量巨大,因此建立本地和PIXIV网站的数据映射,采用SQLite作为爬虫使用的本地数据库。
根据爬虫的需求分析,绘制编写出来的数据库逻辑模型如下表 5‑1所示:
表 5‑1 数据库逻辑模型
PIXIV爬取
PIXIV爬取使用python3的pixivpy3库。
由于pixivpy3不支持等待设置,爬取的时候可能会被PIXIV识别并拦截,因此需要对AppPixivAPI.requests_call打上猴子补丁。补丁代码如下:
old_requests_call = AppPixivAPI.requests_call
defrequests_call(self, method, url, headers={}, params=None,
data=None, stream=False):
sleep(0.5)
returnold_requests_call(self,method, url, headers, params=params,
data=data, stream=stream)
AppPixivAPI.requests_call = requests_call
初始化AppPixivAPI类,实例名为api。挂载HTTPAdapter,设置重试次数为5次。使用账号密码登录PIXIV。输入一个用户ID。将这个用户转换为User实例保存到python的字典类型变量user_dict中。
进入DFS递归。通过api实例的user_detail获取用户的详细信息。从获取到的JSON类中读取用户名字、账号名、头像地址。如果递归层数已经超过指定的深度,则跳出循环。设置当前用户名为已经访问。通过api.user_illusts获取用户作品。截断前若干项。通过api.user_bookmarks_illust函数获取用户收藏作品。同样截断前若干项。混合两个列表进行循环遍历。使用作品ID实例化类Illustration。获取作品标题、作者ID和详细。由于PIXIV上,每一个作品都可以包含若干张图片,这里只获取封面(也就是第一张)的大图地址。判断这个作者ID是否在user_dict中,如果存在则返回User的实例引用,否则实例化一个新的User变量存在user_dict中并返回。对每一个标签进行解包处理,转化为一个每一项均为str类型的一维数组。把上述信息添加到当前作品的实例中。如果当前用户是当前正在遍历作品的作者,则在作品列表添加进入当前作品的实例的引用,否则添加到当前用户的收藏中,并且判断这个作品的作者的作品列表中是否已经包含这个作品,如果未存在则添加到其作品列表中。把当前遍历的作品实例添加到列表中,等循环结束后返回。
获取当前用户的关注者,截断前若干项。遍历关注者,判断其ID是否在user_dict变量中,如果存在则返回实例,不存在则实例化后返回。添加返回的实例到当前用户实例中的followers字段列表中。变量结束后。获取当前用户的被关注者,同样截断前若干项,具体的操作方法同前面的followers处理雷同。保存到用户实例中的followings字段列表中。返回两个列表混合列表。
合并前两段返回用户列表,对每一个用户判断其是否被访问过。如果未被访问过,则继续DFS,递归深度+1。
扫描添加用户和及其作品
首先,MySQL和SQLite采用惰性连接和长连接。由于对爬虫的数据安全性、稳定性要求不高,而对性能敏感。这里特别对SQLite进行优化。使用PRAGMA
synchronous =
OFF命令设置磁盘同步模式为不进行同步,提高大于50倍甚至更多的性能。使用命令PRAGMA
journal_mode =
MEMORY设置日志记录保留在内存中,而不是磁盘上。同样加速SQLite写入而且还能保证发生意外的数据完整性和一致性。具体SQLite连接代码:
_connect: Connection =None
defget_connect() -> Connection:
global_connect
if not_connect:
_connect = connect(sqlite_file_name)
_connect.execute(“PRAGMA synchronous = OFF”)
_connect.execute(“PRAGMA journal_mode = MEMORY”)
return_connect
MySQL连接的代码:
_connect: Connection =None
defget_connect() -> Connection:
global_connect
if not_connect:
_connect = connect(‘127.0.0.1’,‘xx’,‘********’,
’moe_drawing’)
return_connect
从user_dict中读取所有的值。遍历每一个用户。查询SQLite中用户表。查询共用语句为’SELECT
target FROM ’ + obj.table_name + ’ WHERE origin = ? LIMIT
1’。这里为了加速查询速度,使用LIMIT
1。如果存在则返回True,否则使用当前用户ID建立新的记录,并且返回False。对上面的返回值求反,结果为真则插入用户到MySQL中,密码默认为SHA3混合盐加密的字符串,其原始密码为123456。获取插入以后自动递增的ID,把该ID当成target_id写入用户实例中,并添加到SQLite映射中。使用刚才添加的用户名和密码登录本画作交流平台,并保持Session。读取MySQL判断用户头像是否存在,如果不存在则下载头像到users目录下,获取下载后的路径使用PIL库压缩图片,图片压缩到80x80大小,保存在thumb文件夹下。发送请求到本平台修改头像。遍历用户的作品。如果这个作品在数据库中不存在,则下载图片到pictures文件夹下,上传作品和其信息到本平台上,并添加到映射到本地数据库。当前用户遍历结束后登出用户。
扫描用户之间关系
对user_dict里面的所有用户进行遍历。遍历关注自己的用户,查询是否已经关注,SQL语句为SELECT
1 FROM follows WHERE follows.artist_id = %s AND follows.follower_id = %s LIMIT
1,其中由于只需判断其存在,因此这里对其SQL查询优化SELECT
1。如果不存在,则建立关系。遍历自己关注的用户,操作同上。遍历自己的收藏作品,操作原理基本上同上。结束以后,将这些所有的更改提交到MySQL数据库中。惰性更新,优化插入速度。
数据挖掘
数据挖掘部分由R语言编写,用于推荐用户可能喜欢的作品。使用plumber库提供API服务。使用两种算法进行推荐投票:KNN最近邻推荐算法和Apriori关联分析。其主要包括数据读取、KNN最近邻推荐算法和Apriori关联分析三个模块。
数据读取
获取MySQL连接,读取三张表收藏表、足迹表、图片表。读取结束后关闭数据库连接。相关代码如下所示:
collections <- dbGetQuery(con, “SELECT user_id,picture_id FROM
collections”)
footprints <- dbGetQuery(con, “SELECT user_id,picture_id FROM footprints”)
pictures <- dbGetQuery(con, “SELECT user_id,id AS picture_id FROM
pictures”)
dbDisconnect(con)
对收藏表和足迹表添加新列代表权重,收藏表权重为5、足迹表权重为1。两张表合并汇总,生成user2picture用户与图片关系的数据帧。
KNN最近邻推荐算法
协同过滤推荐方法的主要思想是利用已有的用户群过去行为或者意见预测当前用户最可能的喜好。早期方法是被称为基于用户的最近邻推荐。其主要思想是给出一个平衡数据集和当前用户ID作为函数的输入,找出当前用户过去有相似偏好的其他用户,然后对当前用户没见过的每个项目p,利用其最近邻对p的评分预测值。
user.item.martrix <- cast(user.picture.table, user_id ~ picture_id, value
= ‘weight.sum’, fill = 0)
将数据帧转换为用户——图片矩阵。添加列名和行名,并去掉第一列。等到的矩阵部分如下图
5‑1所示:
图 5-1 ‑ 用户——图片矩阵
其中列名为图片ID,行名为用户ID。
接下来计算图片之间距离。使用cor函数计算每一个图片之间的相关系数。设置变量名为sim_cor,其取值范围为[-1,1]。当sim_cor为-1的时候,距离为无求大;当sim_cor为1的时候距离为0。这里要对结果做一次函数-log((sim_cor
/ 2) +
0.5)函数映射。值域为[0,+∞]。最后设置行名称和列名称,同用户——图片矩阵一样。
通过图片ID查找与其最近的K个图片ID。首先查找图片在distance中序号。之后对向量的取值从小到大排序。返回其中最小的数值2+k+1个元素序列号。因为与图片item.id最近的就是自己,因此从第二个开始取(R语中下标从1开始)。反查出所有物品的ID。为此编写为一个函数:
knn.user.item <-function(user.id,item.id,user.item.martrix, distance,
k = 25) {
item.index <- which(rownames(distance) == as.character (item.id))
k.nearest.item.index <- order(distance[item.index, ])[2:(k + 1)]
k.nearest.item.id <- as.numeric(rownames(distance)[k.nearest.item.index])
sum(user.item.martrix[as.character(user.id),
as.character(k.nearest.item.id)]) /
k
}
使用自定义核心函数knn.user.item可以计算用户与所有图片的关系数据。对每个图片遍历,对knn.user.item结果从大到小排序,返回其图片编号。去除所有已经访问过的,去除自己的。返回图片编号。KNN算法结束。其函数代码如下:
knn.itembase <-function(user.id, user.item.martrix, distance,
user.own.pictures, k = 25, return.item.num = 10) {
knn.user.id <- 0
for(iin1:nrow(distance)) {
knn.user.id[i] <- knn.user.item(user.id, rownames(distance)[i],
user.item.martrix, distance, k = k)}
return.item.id <- rownames(distance)[order(knn.user.id, decreasing = TRUE)]
return.item.id <- setdiff(return.item.id,
colnames(user.item.martrix)[which(user.item.martrix[as.character(user.id),]
!= 0)])
return.item.id <- setdiff(return.item.id,
user.own.pictures[which(user.own.pictures[,1]==user.id),2])
return.item.id[1:min(length(return.item.id),return.item.num)]}
Apriori关联分析
关联分析主要是用于从数据集中发现数据项之间的关系。信任度表示项A对项B关联性。计算公式为confidence(A=>B)=P{B|A}=P{AB}/P{A}。支持度是用来衡量同时满足购买A和购买B的概率。其计算公式为:support(A=>B)=P{AB}。提升度为某用户在购买A后推荐B购买的概率相对于不做任何推荐购买时候的概率的提升度,其计算公式为:lift(A=>B)=
lift(A=>B)= confidence(A=>B) / support(B)。
Apriori核心原理:如果一个项是频繁的,那么它的所有子集都是频繁的;如果一个项是非频繁的,那么它的所有子集都是黑频繁的。
主要步骤为:
首先扫描初始长度为1的候选项集,去掉不满足最小支持度的项,得到长度为1的频繁项集。
在上一次迭代长度为k-1的频繁项集上,产生k的候选集。
在长度为k的候选集中,除去k-1的非频繁项集的候选集。
在当前获取的长度为k的候选集中,去掉不满足最小支持度的项,得到频项集。
重复上述2-4步,直到没有新的候选集产生。
首先,从用户图片表中抽取出当前用户喜欢图片向量。储存备用。将user.item.martrix矩阵化为只包含0、1的布尔型矩阵。将其转换为Apriori需要的transactions类型,调用arules库中的apriori函数。由于爬取的数据矩阵过于稀疏,这里支持度取0.1%,置信度取30%。选出lhs(关联规则左边)为用户喜欢的作品的子集。过滤掉那些小于等于1的关联规则。最后结果对提升度从大到小排序。最后过滤去用户已经看过和自己的作品。相关函数为:
knn.itembase <-function(user.id,user.item.martrix,distance,
user.own.pictures, k = 25,return.item.num = 10) {
knn.user.id <- 0
for(iin1:nrow(distance)) {
knn.user.id[i] <-knn.user.item(user.id, rownames(distance)[i],
user.item.martrix, distance,k = k)}
return.item.id <- rownames(distance)[order(knn.user.id, decreasing = TRUE)]
return.item.id <- setdiff(return.item.id,
colnames(user.item.martrix)[which(user.item.martrix[as.character(user.id),]
!= 0)])
return.item.id <- setdiff(return.item.id, user.own.pictures
[which(user.own.pictures[, 1] ==user.id), 2])
return.item.id[1:min(length(return.item.id), return.item.num)]}
最后将前面两种算法综合起来投票。每个算法占1票。汇总统计投票结果,截断前6项。如果推荐项不够,则使用随机推荐来凑齐。
消息投递
由于PHP本身是不支持异步的,并且每个PHP请求都有限制执行的时间和内存大小,再者PHP是并发的,如果某个PHP请求长时间占用MySQL连接不放,会造成MySQL连接池告急。因此用户订阅的消息投递需要单独地抽出来一块,独立成一个服务器。这里选择Python3,使用Flask作为web服务器框架。选择它的原因主要是因为Python对各种不同的文字语言处理得当,并且Flask有很好的执行效率、小巧的体积和资源占用。消息投递服务器主要分为API接口和投递消息事件处理两个模块。
API接口
API接口包括新作品通知、作品删除通知、新评论通知以及删除评论通知。API接口的参数分别如表
5‑2、表 5‑3、表 5‑4、表 5‑5所示:
表 5‑2 新作品通知
表 5‑3 作品删除通知
表 5‑4 新评论通知
表 5‑5 删除评论通知
投递消息事件处理
每个API接口获取到的变量都保存到一个实例化的类中,这些类都是基于一个名为PostEvent的空类派生而成。所有的事件类都推入_event_queue这个队列中,使用一个线程进行处理这些消息。当没有事件进入的时候,线程会挂起休息,直到传入唤醒线程的信号量为止。为单线程堵塞式模型。之所以使用这个模型,是因为这个模型具有高效的投递效率,在大量需要投递消息面前,不需要因为切换上下文而造成损失,另外用户对于订阅的消息推送的时延要求并不是特别高。
如事件队列不为空,则取出事件。使用Python的内置函数isinstance来判断该对象的类型。如果为NewArtworkEvent,则表明其为新作品通知,则添加新数据到数据库,其中接收者为所有关注这个用户的人。消息表中名content的JSON字段内容为{author,title,pid}。
如果为RemoveArtworkEvent,则表明为删除作品通知,先获取作品的ID,如果图片不存在则输出错误,否则判断是否是管理员删除的,如果是管理员删除的则通知作者。之后再通知所有关注的用户和收藏作品的用户,其中如果是关注用户则conten字段中relation为follow,如果为是收藏该图片的用户的话,其内容为collect,如果既收藏又关注的话,则同关注结果。消息表中名content的JSON字段内容为{pid,author,title,relation:(‘follow’|‘collect’|NULL)
[,admin,reason]}。
如果其类型为CommentEvent,则表明事件为新评论通知,先获取图片ID。如果评论本身作者本人的话,则通知作者。如果这个是回复的话,则通知所有被回复的人(其中不包含回复者本人)。消息表中名content的JSON字段内容为{pid,cid,author,content,reply}。
如果该事件类型为RemoveCommentEvent,则表明其为删除评论通知,如果是管理员删除或者是作品的作者删除的话,则通知评论者。消息表中名content的JSON字段内容为{pid,cid,content,operator,[,admin,reason]}。具体的代码实现见附录1
。
客户端实现
前端使用tabbar底层导航栏导航,采用vue-rouer跳转,使用vuex管理状态,运用axios封装AJAX请求。主要页面分为用户页面、最新页面、新作品页面、消息页面、直播页面。
页面初始化
启动前初始化状态管理Vuex、axios。对process.env.NODE_ENV
进行判断,如果是开发模式,则指定为本地localhost地址,否则指向部署网址。将默认ThinkPHP的入口文件index.php/index网址绑定在axios实例上,设置每次发送的头为’X-Requested-With’:
‘XMLHttpRequest’,使得服务器识别该头,返回JSON格式的响应。添加响应拦截器拒绝并在控制台(仅仅在开发环境下有效。当应用在打包发布以后,所有的控制台输出函数都会自动被删除)所有的错误。Vuex实例化为store。Axios实例化为ajax。
添加简单历史管理,将跳转状态写入store,其中对ISO左滑返回特判。这些标识用于实现滑动翻页特效。
从user/getSalt地址获取来自服务器加密的盐。挂载一些全局到Vue实例,并且将router、store、ajax挂载上。当vue实例被创建的时候,如果是生产模式则从localStorage中读取用户信息,否则从sessionStorage读取信息。
用户页面的实现
如果已经保存了用户信息,则对this.$stroe.userInfo对象解构,渲染页面和用户信息管理选项,否则提示用户去登录。用户界面包括使用账号密码登录、使用短信登录、注销、修改密码、修改手机号、上传头像、注销账号和用户首页这些子功能页面。
其中在未登录之前,用户页面只有引导登录的文字,如图 5‑2所示:
图 5-2 ‑ 未登录的用户页面
当用户登录以后,则显示用户信息和用户管理区域。效果如图 5‑3所示:
图 5-3 ‑ 登录以后的用户页面
使用账号密码登录
登录页面的实现效果如图 5‑4所示。
图 5-4 ‑ 登录页面
密码加密方式为原始密码两头加上盐使用SHA3-512加密成新密码,新密码再接着两头加上盐加密,这样循环5次。防止有人恶意利用彩虹表暴力破解原始密码。盐为固定字符串储存在服务器上,根据现有的技术,几十年之内都不会被破解。具体代码如下:
Vue.prototype.$pwdEncrypt = (value) => {
letpwdString = value
for(leti = 0; i < 5; i++) {
pwdString = salt + pwdString + salt
letsha =newSHA(‘SHA3-512’,‘TEXT’)
sha.update(pwdString)
pwdString = sha.getHash(‘HEX’)
}
returnpwdString
}
账号和密码发送到
地址中。其中index.php为ThinkPHP的入口文件,index为模块名,User为控制器类名称,login为其中函数。服务器先判断用户名是否存在。如果存在则保存在Session内以便以后请求使用。
static private functionsaveUserInfo(array$user): void
{
session(‘user’, $user);
}
session为ThinkPHP助手函数,保存session名字为user,保存整个user数组,user变量内部结构为id,
uid, phone_number, icon_paths, role, nickname。登录成功返回[status:true,
user_info: [id, uid, phone_number, icon_paths, role, nickname]],
失败返回[status:false, msg:‘账号不存在或者账号密码不正确!’]。
处理获取到的json。由于vuex内容修改的时候需要同步提交,不支持异步修改。因此需要提交于userInfoSave状态到store。解构转换json内容,根据当前环境选择保存的位置。具体代码如下:
userInfoSave (state, userInfo) {
state.artworkList= {}
state.messageUser= []
state.messageSystem= []
state.userList= {}
state.lastFetchTime=null
if(userInfo) {
lettranslation = {
icon: userInfo[‘icon_paths’] || userInfo[‘icon’],
id: userInfo[‘id’],
userId: userInfo[‘uid’],
nickname: userInfo[‘nickname’],
phoneNumber: userInfo[‘phone_number’] ||
userInfo[‘phoneNumber’],
role: userInfo[‘role’]
}
state.userInfo= translation
if(process && process.env.NODE_ENV===‘development’) {
localStorage.setItem(‘userInfo’,JSON.stringify(translation))
}else{
sessionStorage.setItem(‘userInfo’,JSON.stringify(translation))}
}else{
state.userInfo=null
if(process && process.env.NODE_ENV===‘development’) {
localStorage.removeItem(‘userInfo’)
}else{
sessionStorage.removeItem(‘userInfo’)}}
登录结束以后抓取最新的消息,具体参考消息页面,之后返回上一个页面。
如果出错则使用$vux.toast弹出错误提示框。
使用短信登录
如果忘记密码,可以使用手机号登录。输入手机号后,跳转到登录验证码验证页面。this.$router.push(’/login-with-phone-validate/’+
this.phoneNumber
)当这个页面被挂载以后,从地址中获取手机号作为参数,发送给服务器。服务器先判断这个手机号是否已经被注册,如果未被注册,将当前用户IP作为对象,生成一个随机的6位验证码,调用存储过程pr_submit_verification。判断存储过程返回的结果。如果正确则调用公共函数send_sms,发送到腾讯云短信服务服务器上。调用代码如下:
if($user = $this->getUserInfo()) {
$object = $user[“id”];
}else{
$object = get_client_ip(0);
}
$code =mt_rand(100000, 999999);
$resultSet = DB::query(‘call
pr_submit_verification(:obj,:met,:cd,:pnum)’
, [“obj”=>$object,“met”=> $method,“cd”=> $code,“pnum”=>
$phone_number
]);
$pr_res = $resultSet[0][0][“res”];
switch($pr_res) {
case “success”:
$send_res = send_sms($phone_number, $code, $method);
if($send_res->result== 0) {
$ret[‘status’] =true;
return$ret;}
$ret[‘msg’] = $send_res->errmsg;
break;
case “run out”:
$ret[‘msg’] =“抱歉,您今天短信次数已经用完,情明天重试。”;
break;
case “exist”:
$ret[‘msg’] =
"您发送的频率过于频繁。2分钟之内只能发送一条短信验证码。";
break;
}
由于限制要等120秒以后才能重新发送短信,前端做了一个定时器,分发异步事件$store.dispatch(‘sendMsgWaitTimerStart’)。
注册
注册页面的实现效果如图 5‑5所示:
图 5-5 ‑ 注册页面
当用户在注册时候延时验证用户名、昵称手机号。填写密码的时候发送密码到服务器。密码验证采用Zxcvbn库,密码强度取值范围为[0,4]。数值0显示红色的“弱”,数值为[1,3]显示为黄色的“中”,数值4显示为绿色的“强”。修改组件库的x-progress,命名为x-progress-a,来实现对不同程度的密码强度显示不同颜色的进度条。此外,密码等于0强度的弱密码不得通过注册。修改组件库的组件x-input为x-input-a实现新属性enforce-validate、新事件@on-valid-change、新插槽right-icon、新方法。发送短信验证服务器端与上面登录验证函数一样。
服务器接收到注册表单,再次验证每个字段是否合法。用户名、昵称、手机号是否唯一。如果判断唯一则添加数据到数据库。具体函数如下:
public functionregister(string $uid, string $nickname, string
$phone_number, string $pwd, string $code):array
{
$ret = [STATUS=>false];
foreach([‘uid’=> $uid,‘nickname’=> $nickname,
’phone_number’=> $phone_number]as$key => $value) {
$check_res = $this->checkRegister($key, $value);
if($check_res[STATUS] ==false) {
$check_res[‘field’] = $key;
return$check_res;}}
$val_res = $this->validateVerification(“注册验证”, $code,
$phone_number);
if($val_res[STATUS]) {
Db::table(‘users’)->insert([‘uid’=> $uid,'phone_number’
=> $phone_number,‘nickname’=> $nickname,‘pwd’=> $pwd]);
$ret[STATUS] =true;
}else{
return$val_res;
}
return$ret;}
如果注册成功提示“注册成功,请返回登录”,并且返回上一页。否则对服务器返回的错误字段映射到具体的Input输入框后红色感叹号内。toast弹出错误提示。如果提示消失,使用者依旧可以从输入框后面的感叹号图标中获取错误提示。
修改密码
修改密码鉴权思路为:用户需要手机短信验证,之后才能修改密码。
点击修改密码,则跳转到手机验证页面,消息发送和短信验证码验证原理基本上同使用短信登录一样,验证码和失效时间会临时写入session中保存120秒。
验证通过以后跳转到修改密码页,修改密码的时候读取之前session,如果找不到或者其已经失效则修改密码失败,提示用户返回继续操作。如果操作成功,则提示退出登录。提交userInfoSave为null,删除当前用户信息,历史记录后退两步,前进到登录页面。
修改手机号
修改手机号基本思路为:用户输入密码,密码验证通过以后发送新手机的验证码,验证通过才能修改手机号。
跳转到密码验证页面,其他操作基本上同上。同样保存在session中,修改成功以后退出当前账号。
修改昵称
默认页面会输入用户原有的昵称。在提交的时候或者用户输入的时候都会验证昵称文本。Template内容为:
<template><div>
<x-header title=“修改昵称”></x-header>
<div style=“margin-top: 10px”>
<group>
<x-input-a ref=“nicknameXInput” title=“新昵称” type=“text”
placeholder=“请输入新昵称” v-model=“nickname” @on-blur=“nicknameValidate”
@on-change="(val)=>
{if(val.length>0&&val!==this.$store.state.userInfo.nickname)this.nicknameValid=true}"
required>
</x-input-a>
</group>
<group>
<x-button type=“primary” @click.native= “changeNickname”
:disabled="!nicknameValid" >确认 </x-button> </group> </div> </div>
</template>
昵称同注册相似,使用lustre/php-dfa-sensitive的PHP库过滤敏感词词汇。基于确定有穷自动机(DFA)算法。函数使用之前判断SENSITIVE_HELPER
_INITIALIZE这个全局常量是否被定义,没有定义的话初始化密码本,密码本的位置从配置文件中读取,并且定义这个常量。具体代码如下:
if(!defined(“SENSITIVE_HELPER_INITIALIZE”)) {
SensitiveHelper::init()->setTreeByFile(config(‘path.sensitive_dict’));
define(“SENSITIVE_HELPER_INITIALIZE”,true);}
return(SensitiveHelper::init()->getBadWord($text, 1))[0] ??
null;
修改昵称成功以后提交新的用户信息到store中,并且提示修改成功。具体代码如下:
changeNickname () {
if(this.nicknameValid) {
let_this =this
this.$ajax.post(‘User/changeNickname’, {
nickname: _this.nickname
}).then(function(resp) {
if(!resp.status) {
_this.$refs.nicknameXInput.setError(resp.msg)
_this.$vux.toast.show({
text: resp.msg,
type:‘warn’})
}else{
letuserInfo = _this.$store.state.userInfo
userInfo.nickname= _this.nickname
_this.$store.commit(‘userInfoSave’, userInfo)
_this.$vux.toast.show({
text:‘修改成功’,
type:‘success’,
isShowMask:true,
onHide () {_this.$router.go(-1)} }) }})
}
}
上传头像
由于JS不能直接读取本地文件,因此在上传头像cell组件中隐藏一个input元素。Input代码如下:
<input ref=“iconInput” type=“file” accept=“image/jpeg,image/png”
style=“display:none” @change=“onFileChange”/>
点击上传头像的cell组件,则触发这个input元素的点击事件**$refs**.iconInput.click()由浏览器去实现本地图片选择。监听input元素类的change事件。Input元素初始内容为空,如果当输入框的内容改变的时候,则说明用户选择图片,则弹出页面弹窗,页内弹窗使用Popup组件实现。由于移动端应用中,每个需要的组件放在每个路由的.vue文件中,当因为此时组件不在body下,加上定位、overflowscrolling设置等原因,会出现遮罩在弹层上面以及z-index失效问题。因此这类的弹窗必须使用v-transfer-dom
指令,自动移动到body下来解决上述问题。图片裁剪画面使用的是vue-cropper组件。vue-cropper是一个优雅的、用于Vue.js框架下的图片裁剪插件。代码如下:
<div v-transfer-dom>
<popup :value=“iconPopupShow” position=“top” @on-show=“onIconPopupShow”
@on-hide=“onIconPopupHide” height=“100%”>
<popup-header left-text=“×” right-text=“√” :show-bottom-border=“false”
@on-click-left=“iconPopupShow=false”
@on-click-right=“changeIcon”></popup-header>
<vue-cropper ref=“cropper” style=“height: calc(100% - 44px)”
:img=“iconFile” :full=“true” :fixed=“true” :fixedBox=“true”>
</vue-cropper></popup></div>
如果选择X按钮则隐藏弹窗,并且设置input元素内容为空。用手指划出截图框,选取截取内容。再次滑动则重新选择。修改头像界面如图
5‑6所示:
图 5-6 ‑ 修改头像页面
如果选择√按钮,则压缩图片尺寸到80x80大小,上传到服务器。服务器先判断是否已经登录。如果登录以后对图片审核。图片审核包括判断类型、大小。这里在linux上有一个特殊的问题,当调用request()一次以后,再次调用request会找不到上传文件的请求特殊问题。因此这里这里拷贝一份request()->file()返回的变量。具体代码如下:
if(!$files)
{$files = $file = request()->file();}
如果是图片类型文件且大小小于设定的大小,则从系统临时文件夹移动服务器public\icon。框架会自动以当前日期创建对应的文件夹,文件重命名为文件的MD5码。图片审核使用百度图片审核服务。使用file_get_contents函数从本地中以二进制的方式读取图片。其中只判断“不合规”的结果。审核类型只只对色情、性感恐怖这些标签处理。如果存在“不合规”的内容,则返回其“不合规”的标签,并且删除图片文件,否则继续,获取图片的保存路径、宽度、高度。如果用户存在则删除原有的头像文件。更新数据库设置头像。客户端上,如果设置头像成功则弹出“修改头像成功!”并且更新本地$store对userInfo提交更改状态。
注销账号
弹出对话框,用户再次确认之后注销账号。注销账号后,所有的有关用户的本地数据都置空,服务器删除session。设图片列表缓存为空、设置消息缓存为空。注销弹窗如图
5‑7所示:
图 5-7 ‑ 注销弹窗
用户主页
点击用户头像即可访问到用户主页。用户主页显示如图 5‑8所示:
图 5-8 ‑ 用户主页
其中热门作品列表下同最新作品的搜索结果。如果不是当前用户,则拥有私信和关注选项。其界面如图
5‑9所示:
图 5-9 ‑ 其他用户的个人首页
点击私信按钮以后会获取消息列表,如果已经在消息列表中则获取其在数组中下标作为序号,否则设置序号为-1。跳转到聊天页面的时候带上用户ID和其序号。具体函数如下所示:
goToChar () {
this.$fetchLastMessage().then(() => {
this.$fetchLastMessage().then(() => {
this.$nextTick(() => {
console.log(this.$store.state.messageUser)
if(!this.$store.state.messageUser.some((message,
index) => {
if(message.uid===this.userId) {
this.$router.push(`/chat/${this.userId}/${index}`)
return true}
})) {
this.$router.push(`/chat/${this.userId}/-1`)
} }) }) })}
最新页面的实现
最新页面包括头部固定块、作品列表和详细页面这三个功能子模块。
头部固定块
头部固定使用的是sticky组件,该组件保持固定。由于该组件存在问题,外面套一个固定高度的DIV防止这个组件向上走。组件内主要包含search、tab、popup-picker组件。Search负责输入搜索、tab用于切换显示和搜索的范围、popup-picker是一个页内弹窗,用于用户选择结果排序的方式。具体代码如下:
<div style=“height: 124px;”>
<sticky id=“sticky” scroll-box=“vux_view_box_body”
:check-sticky-support=“false”>
<div><search @on-submit=“search” :auto-fixed=“false” v-model=“keyWords”
></search>
<tab v-model=“tabIndex”> <tab-item selected
@on-item-click=“itemClick”>最新</tab-item>
<tab-item :disabled="!userId" @on-item-click= “itemClick” >收藏夹
</tab-item>
<tab-item :disabled="!userId" @on-item-click= “itemClick”>我的
</tab-item> </tab>
<popup-picker style=“z-index: 50” class=“picker-div” title=“排序方式”
v-model=“pickerValue” :data=“pickerData”
@on-hide=“pickerHide”></popup-picker> </div> </sticky> </div>
搜索的时候会对用户选择的范围转换为英文请求的参数。将范围、关键字、排序方式发送给服务器。如果是默认排序的话,则开启迅搜引擎搜索,构造搜索语句,发送给本地迅搜服务器。如果非默认情况则使用百度云提供的自然语言处理服务,分隔关键词为每个单词向量。左连接标签表和标签库,查标题和标签是否包含这些关键词向量。如果为指定排序方式,则根据排序字段猜测需要如何排序。如果范围为最新,这里还需要建立一个子查询,负责查询那些被收藏。由于迅搜返回的是一组根据相关度排序图片id,搜索结束后要重新对结果排序。从图片查询结果中获取出每个图片的作者信息。
本函数过于复杂,是所有获取作品列表的核心,是整个系统中耗时和资源最多的地方。所有的SQL语句都经过优化查询。具体实现见附录2
。
作品列表
显示作品的列表,分两个模块:相关推荐和搜索结果模块(空关键词也算搜索)。相关推荐如图
5‑10所示:
图 5-10 ‑ 相关推荐页面
相关推荐由上述的R语言实现,其结果使用getList函数处理,显示方式同下诉的搜索结果作品列表一样。
搜索作品列表是最新页面核心的内容。整体的图片采用瀑布流显示,每张图片平分页面的宽度,高度自动补全。页面内容如下图
5‑11所示:
图 5-11 ‑ 搜索结果页面
由waterfall实现图片瀑布流,vue-pull-to集成下拉刷新、上拉加载、无限滚动功能。但是由于其与ViewBox和Stick组件冲突问题,高度和内容高度均改为新函数。为了能够获取viewbox中内容,组件从父组件注入viewbox这个函数来获取viewbox。具体代码如下:
getClientHeight () {
letviewBox =this.viewBox()
letheight = viewBox.getScrollBody().offsetHeight
height -=document.getElementById(‘sticky’).clientHeight
returnheight
},
getScrollTop () {
return this.viewBox().getScrollTop()
},
getHeight (oldHeight) {
return this.width/this.$store.state.width*
oldHeight
}
由于拉动的时候加载的提示字符串会盖住stick顶层固定框中的排序选择组件,因此特殊给这个组件加上z-index:
50,来保证其位于最上方。
如果滑动到一定深度的时候,友好的显示返回顶部按钮。滑动深度注入vue-pull-to组件内。
在离开这个组件的时候,记录所有的图片和滚动位置,当返回当前页面的时候自动滚动到用户当前访问的位置,方便用户体验。但是由于页面的位置需要等待浏览器渲染结束以后才拥有,因此使用setTimeout设置1秒以后跳转到之前位置。
点击头像会跳转到对应的用户用户首页,点击作品列表中的图片则跳转到对应作品的详细页面。
详细页面
当进入这个页面的时候默认加载图片详细。获取详细页面服务端代码如下:
public functiongetDetails(int $pid):array{
$this->setVisited([$pid]);
$ret = [STATUS=>false];
$sql_res = Db::table(‘pictures’)->join(‘users’,
’pictures.user_id = users.id’) ->field(‘title,file_paths,
update_datetime,details,collect_num,user_id,icon_paths,nickname,width,height’
)->where(‘active’,true) ->where(‘pictures.id’, $pid)
->find();
if($sql_res) {$ret[‘picture’] = $sql_res;
$ret[‘picture’][‘labels’] = Db::table(‘picture_labels’)
->join(‘labels’,‘label_id = id’) ->where(‘picture_id’, $pid)
->field(‘text’) ->select();
if(($user = User::getUserInfo()) && $user[‘id’] !=
$sql_res[‘user_id’]) {
$sql_res = Db::table(‘collections’) ->where(‘user_id’,
$user[‘id’]) ->where(‘picture_id’, $pid) ->field(‘1’)
->find();
$ret[‘picture’][‘collected’] = $sql_res ?true:false;}
$ret[STATUS] =true;
}else{$ret[MSG] =‘作品不存在!’;}
return$ret;}
作品详细显示出来的效果如图 5‑12所示:
图 5-12 ‑ 详细页面
评论使用了网易新网的盖楼模式。实际模式和代码均手动实现。整体的思路如下:在数据库保存一张评论表和回复表,回复表包含评论ID和回复ID的路径,之后分隔回复路径,反查这些评论ID。其中每个路径都是完整的路径,也就不需要闭包查询。这种设计的优点是插入迅速,可以设置一个存储过程快速插入,数据冗余少,缺点为查询的时候效率较低,需要where
in一个数组。由于MySQL没有分隔字符串的函数,因此此处分隔字符串由PHP来完成。具体的函数如下所示:
$sql = Db::table(‘comments’)
->where(‘active’, true)
->where(‘picture_id’, $pid)
->join(‘replies’, ‘replies.id = comments.id’, ‘left’)
->join(‘users’, ‘user_id = users.id’)
->field(‘comments.id AS
id,content,comment_datetime,parent_id_paths,user_id,nickname,icon_paths’)
->order(‘comment_datetime’, ‘DESC’)
->limit(config(‘myconfig.page_number’));
if ($early_time) { $sql->where(‘comment_datetime’, ‘<’, $early_time);}
$ret[‘comments’] = $comments = $sql->select();
$reply_id = [];
foreach ($comments as $comment) {
if ($comment[‘parent_id_paths’]) {
$parents =explode(’,’, $comment[‘parent_id_paths’]);
foreach ($parents as $parent) {
if (!in_array($parent, $reply_id)) {
array_push($reply_id, $parent); } } }}
if ($comments) {
$replies = Db::table(‘comments’)
->where([‘comments.id’ => $reply_id])
->where(‘active’, true)
->join(‘users’, ‘user_id = users.id’)
->field(‘comments.id AS
id,content,comment_datetime,user_id,nickname,icon_paths’)
->select();
$ret[‘replies’] = $replies;}
之后,客户端对获取到的评论和回复评论处理,如果回复的评论中包含已经被删除的评论,则显示“该条的评论已被删除!”。具体处理的部分代码如下所示:
letreplies = resp[‘replies’]
if(replies) {
for(leti = 0; i < replies.length; i++) {
replies[i][‘content’] = _this.$decodeText(replies[i][‘content’])
_this.replies[replies[i][‘id’]] = replies[i] }}
letcomments = resp[‘comments’]
if(comments.length< _this.$store.state.pageNumber)
{
_this.isEnd=true
}
if(comments) {
for(leti = 0; i < comments.length; i++) {
comments[i][‘content’] = _this.$decodeText(comments[i][‘content’])
if(comments[i][‘parent_id_paths’]) {
letparentReplies = comments[i][‘parent_id_paths’].split(’,’)
comments[i][‘replies’] = []
for(letj = 0; j < parentReplies.length; j++) {
if(_this.replies.hasOwnProperty(parentReplies[j])) {
comments[i][‘replies’].push(_this.replies[parentReplies[j]])
}else{
comments[i][‘replies’].push({content:‘该条的评论已被删除!’,
isDelete:true}) } }
comments[i][‘outLimit’] = (parentReplies.length> 6)
}else{ comments[i][‘parent_id_paths’] = [] }
_this.comments.push(comments[i]) }}
其中对评论内容进行富文本解码。其中文本包含自定义标签来实现表情插入。防止XSS跨域攻击。实现代码如下:
returntext.replace(’&’,‘&’)
.replace(’<’,‘<’)
.replace(’>’,‘>’)
.replace(’"’,‘"’)
.replace(’\’’,‘'’)
.replace(’/’,‘/’)
.replace(/#####emoji:(.*?)#####/g,‘<img
src=\’/static/img/$1\’/>’
)
服务器返回的日期基本上都是形式如同"-03-14
22:07:05"这样生硬的时间字符串,不能给用户友好的体验感。对时间字符串使用友好处理,使其显示为用户可以良好体验感受的时间,比如“1分钟前”、“1小时前”、“1天前”等这样的字符串。对此功能封装为一个全局函数,挂载在vue到实例中,函数代码如下:
Vue.prototype.$getFriendlyTime =function(str) {
letcurrentTime =new Date()
letarr = str.split(/\s+/gi)
letarr1, arr2, oldTime, delta
letgetIntValue=function(ss, defaultValue) {
try{
returnparseInt(ss, 10) ?parseInt(ss, 10) : defaultValue
}catch(e) {
returndefaultValue
} }
letgetWidthString=function(num) {
returnnum < 10 ? (‘0’+ num) : num
}
if(arr.length>= 2) {
arr1 = arr[0].split(/[/-]/gi)
arr2 = arr[1].split(’:’)
oldTime =new Date()
oldTime.setYear(getIntValue(arr1[0], currentTime.getFullYear()))
oldTime.setMonth(getIntValue(arr1[1], currentTime.getMonth() + 1) - 1)
oldTime.setDate(getIntValue(arr1[2], currentTime.getDate()))
oldTime.setHours(getIntValue(arr2[0], currentTime.getHours()))
oldTime.setMinutes(getIntValue(arr2[1], currentTime.getMinutes()))
oldTime.setSeconds(getIntValue(arr2[2], currentTime.getSeconds()))
delta = currentTime.getTime() - oldTime.getTime()
if(delta <= 6000) {
return '1分钟内’
}else if(delta < 60 * 60 * 1000) {
return Math.floor(delta / (60 * 1000)) +'分钟前’
}else if(delta < 24 * 60 * 60 * 1000) {
return Math.floor(delta / (60 * 60 * 1000)) +'小时前’
}else if(delta < 3 * 24 * 60 * 60 * 1000) {
return Math.floor(delta / (24 * 60 * 60 * 1000)) +'天前’
}else if(currentTime.getFullYear() !== oldTime.getFullYear()) {
return[getWidthString(oldTime.getFullYear()),
getWidthString(oldTime.getMonth() + 1),
getWidthString(oldTime.getDate())].join(’-’)
}else{
return[getWidthString(oldTime.getMonth() + 1),
getWidthString(oldTime.getDate())].join(’-’)
}
}
return ‘’}
评论输入框始终悬浮在最下方。由于input元素类不能插入图片,因此使用div加上contenteditable属性作为评论的输入框。与此同时,其产生了光标定位的问题。网络上面没有正确的解决方案,这里给出设计思路:如果是第一次输入,当输入第一个字符的时候,提交更新内容到父组件,并且设置光标为这个字符的后面位置。当这个组件失去焦点的时候,提交更新到父组件。具体已经整合为一个可以复用的输入框组件,组件代码如下:
<template>
<div class=“text” v-html=“innerText” :contenteditable=“canEdit”
@focus=“isLocked = true” @blur=“isLocked = false” @input= “changeText”>
</div>
</template>
<script>
export default {
name: ‘editDiv’,
props: {
value: {
type:String, default: ‘’
},
canEdit: {
type:Boolean, default: true
}
},
data () {
return {
innerText: this.value,
isLocked: false,
isFistTime: true
} },
watch: {
value () {
if (!this.isLocked || !this.innerText) {
if (!this.innerText) {
this.$nextTick(() => {
this.keepLastIndex(this.$el)
}) }
this.innerText = this.value
}
this.isFistTime = false
},
isLocked () {
this.innerText = this.value
} },
methods: {
changeText () {
this.$emit(‘input’, this.$el.innerHTML)
},
keepLastIndex (obj) {
if (window.getSelection) {//
obj.focus()
let range =window.getSelection()//
range.selectAllChildren(obj)//
range.collapseToEnd()//
} else if (document.selection) {//
let range =document.selection.createRange()//
range.moveToElementText(obj)//
range.collapse(false)//
range.select()
}
} } }
</script>
<style lang=“less”>
@import “…/…/styles/comment”;
.text {
img {
width: 4em;
height: 4em;
} }
</style>
评论区域的实现效果如图 5‑13所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nkzIjlld-1591608348884)(//06/08/doPJxm31yak9Z8N.png)]
图 5-13 ‑ 详细页面评论区域
点击旁边小飞机图标则发送消息到服务器。PHP服务器对评论鉴权通过后,处理评论,并添加到MySQL数据库。如果是回复则调用MySQL中预先编写好的存储过程pr_reply_comment,创建回复路径。之后把其发给python编写的服务器进行消息投递。如果评论发布成功,客户端显示通过提示并且重载页面。重载页面来自父组件,使用inject注入,inject:
[‘reload’]。重载页面函数保证不在刷新页面的情况下,重新加载当前子页面。其函数实现如下:
reload () {
this.isRouterAlive = false
this.$nextTick(function () {
this.isRouterAlive = true
})}
新作品的页面实现
新作品包含标题、图片选择框、标签、详细内容和确认按钮组成。图片上传使用picture-input组件。选择中的图片会在这个组件内部渲染。标签使用了vue-tags-input组件,当输入文字的时候,会从服务器获取标签补全候选框。标签补全函数如下所示:
public functionlabelComplete(string $label):array{
$label =trim($label);
$res = Db::table(‘labels’)
->whereLike(‘text’,‘%’. $label .‘%’)
->order(‘num’,‘desc’)
->limit(6)
->field(‘text,num’)
->select();
return$res;}
当图片改变的时候,会判断标题时候为空,为空则填入图片的名称。涉及函数如下:
onChange (image) {
this.hasImage=true
if(!this.title) {
lettitle =this.$refs.pictureInput.file.name
this.title= title.substring(0, title.indexOf(’.’))
}}
新作品的页面如图 5‑14所示:
图 5-14 ‑ 新作品页面
上传时候使用Jimp库对图片压缩。Jimp是一个包含各种插件的JS图片库,由于这里只需要读取并且修改图片尺寸,因此只读取这两个插件。具体自定义代码如下:
importconfigurefrom ‘@jimp/custom’
importtypesfrom ‘@jimp/types’
importresizefrom ‘@jimp/plugin-resize’
export defaultconfigure({
types: [types],
plugins: [resize]
})
服务器接收到作品,对作品的每一个字段进行合法性检查。检查通过以后,添加到数据库。
此处提及一下标签的数据库处理和优化。这里对图片的标签垂直切割。分隔成labels和picture_labels两张表。Labels存放标签的本身和使用数。使用数采用触发器更新。这里不考虑使用虚拟列的原因为标签查询很频繁,相比插入使用的频率很少,因此这里产生冗余列。Picture_labels储存标签和图片更新。垂直分隔的理由为:当标签库足够大,大到包含所有图片可能的标签的时候,这种方法最优。而且事实上根据爬取的数据表明,作品使用的标签总是集中于很小的一个标签集合。上传上传成功则弹出提示框,返回则重载当前页面,或者前往作品列表页面。上传作品是这个画作交流平台的重要组成的一部分,具体客户端和服务器端代码的实现见附录3
和附录4 。
消息页面的实现
消息页面包括消息列表、聊天页面这两个功能子模块。
消息列表
当挂载消息列表的时候,自动获取消息。获取消息采用Promise封装。众所周知,JavaScript的执行环境是单线程,不支持多线程。为了满足JS异步操作的需求,ES6突出了Promise新概念。获取到服务器传回的消息列表的后,再获取所有需要获取的用户信息。把这些消息写入$store中缓存使用。
Vuex状态管理中对消息处理。如果是用户消息,则根据用户ID,分组合并,如果是系统话直接push入数组。
当这些做完以后,调用resolve(),否则调用reject(resp[‘msg’]),其中参数为错误信息。
使用swipeout组件,实现左拖动的时候,显示下面删除的按钮,合理利用空间。消息列表封装为一个单独的vue文件。根据用户在tab组件上的切换,渲染不同内容。当用户滑动右边的时候显示系统消息。效果如图
5‑15所示:
图 5-15 ‑ 显示系统消息的消息页面
当用户滑动到Tab的左边的时候,渲染一个用户列表,其中所有用户的私聊均按照用户分组显示,消息截断为两行,点击头像即可进入聊天页面。页面的具体实现效果如图
5‑16所示:
图 5-16 ‑ 显示用户消息的消息页面
聊天页面
当点击用户头像即可进入私聊页面。当页面被挂载的时候,设置以上消息为已读,设置一个定时函数,等待一段时间确保页面被渲染结束以后,跳转到页尾。挂载的代码如下所示:
mounted () {
this.setRead()
setTimeout(() => {
this.$nextTick(() => {
letviewBox =this.viewBox()
letscrollBody = viewBox.getScrollBody()
letscrollTopx = scrollBody.scrollHeight-
scrollBody.clientHeight
viewBox.scrollTo(scrollTopx)
})
}, 1000)}
具体的聊天的内容和消息发送框同评论的实现大致相同。页面效果如图 5‑17所示:
图 5-17 ‑ 聊天页面
直播页面的实现
直播使用vue-video-player。直播分推流和播流两个部分。由于阿里云默认开启URL鉴权,并且不可关闭。URL鉴权功能的目的是保护用户站点的资源不被非法下载盗用。如果采用防盗链方法添加referer,可以解决部分盗链问题。但由于referer内容可以伪造,因此这种方法并不能保护站点。
鉴权URL由对应的地址+验证串组合而成。验证串=鉴权key+失效时间通过MD5计算出。具体计算方法见代码:
private functiongetUrl(string $stream_name, bool $is_broadcast =
false, string $broadcast_type =‘’): string{
$time_now =strtotime(’+2 hour’);
$rand =bin2hex(openssl_random_pseudo_bytes(16));
$private_key = $is_broadcast ?
config(‘api.aliyun.broadcast_private_key’) :
config(‘api.aliyun.push_stream_private_key’);
if($is_broadcast){
$broadcast_type=str($broadcast_type);
switch($broadcast_type) {
case ‘rtmp’:
$base_url =‘rtmp://’;
break;
case ‘flv’:
$base_url =‘’;
$stream_name .=‘.flv’;
break;
case ‘hls’:
$base_url =‘’;
$stream_name .=‘.m3u8’; }
}else{
$base_url =‘rtmp://’;
}
$ssurl =sprintf(’/md/%s-%u-%s-0-’, $stream_name, $time_now,
$rand); $hash_value = $ssurl . $private_key;
$hash_value =md5($hash_value);
$ssurl =sprintf(’%s/md/%s?auth_key=%s-%s-0-%s’, $base_url,
$stream_name, $time_now, $rand, $hash_value);
return$ssurl;
}
播流包含三个地址,分别为rtmp、flv、hls协议。HLS播放协议是苹果研发的,对浏览器兼容好,且支持跨平台。但是由于HLS本身机制问题,是基于大颗粒的TS分片流媒体协议。每个分片有着至少5s的时长,分片数量大部分情况下是3-4个,所以计算出来的延时在20-30s左右。RTMP为Adobe为Flash播放器和服务器组件音视频数据传输开发的私有协议。FLV是Adobe公司推出的另外一种视频格式,是一种在网络上传输的流媒体数据存储容器格式。但是经过检测安卓手机上普遍没有Flash播放器,rtmp、flv这些低延迟的协议,均不能使用。只能使用hls格式。客户端获取到播流地址,添加到video-player组件内。直播页面如图
5‑18所示:
图 5-18 ‑ 直播页面
构建Android原生应用
安装Cordova,创建一个新项目,删除Cordova自带的网页模板。编写一个bat,自动吧webpack打包好的前端页面导入Cordova项目下的www文件夹中。使用Cordova
platform add android添加android平台。
因为在移动端上,自定义Video标签播放弹幕的效率不好。因此,这里的直播弹幕功能只在原生Android
APP上实现。其他内容均和浏览器端功能一致,便不再赘述。接下来重点阐述的是在Android原生APP上的直播弹幕功能实现。
因为需要对原生项应用添加新功能,因此这里使用cordova prepare指令来构建出Android
Studio项目,然后使用Android Studio打开项目。
这里提一下Android构建版本选择的问题。需要选择最新的Android版本作为target和compile的版本,在最低的版本上指定你的所期望的最低版本,比如这里使用API
19(对应Android版本为4.4),这样就可以保证Android应用的向前兼容性。
这里使用了GSYVideoPlayer作为直播播放器。GSYVideoPlayer基于IJKPlayer(兼容系统MediaPlayer与EXOPlayer2),实现了多功能的播放器。而IJKPlayer是基于FFmpeg
n3.4,支持MediaCodec和VideoToolbox的Android/IOS播放器。FFmpeg是使用C和汇编编写的一套用于记录、转换数字音频和视频,并能够将其转为流的开源计算机程序。它诞生于Linux,且能在其他平台上编译运行。而IJKPlayer是Bilibili公司的开源一款成熟播放器,保证了其效率和运行的稳定性。
MQTT是一种基于客户端——服务器的消息发布/订阅的传输协议。MQTT是轻量、简单、开放和易于实现的。这里使用其作为弹幕收发的协议,是因为直播有大量的代码需要收发,且当一个用户发送弹幕的时候,同时观看此直播的用户均要接收此弹幕,MQTT正好适用于此场景。协议的Qos服务质量设置为0,即发送者只发送一次消息,无论服务器有没有收到,都不进行重试。因为仅仅一条弹幕发送是否成功并不是很重要,且弹幕本身具有极强的实时性,一旦超过了几秒,就算发出来也没有任何意义,此外还能减轻服务器的压力。
MQTT服务器采用GO语言编写,使用surgemq的MQTT来提供高效的MQTT服务器。服务器端代码如下所示:
packagemain
import(
"fmt"
"/surgemq/surgemq/service"
)
funcmain() {
// Create a new server
svr := &service.Server{
KeepAlive: 300,
ConnectTimeout: 2,
SessionsProvider:“mem”,
Authenticator:“mockSuccess”,
TopicsProvider:“mem”,
}
err := svr.ListenAndServe(“tcp://:1883”)
fmt.Printf("%v", err)
}
MQTT客户端使用fusesource的mqtt-client。原本官方的为Android提供的eclipse版本已经过时,因此这里使用的第三方开源mqtt客户端。
Android端在原先的基础上添加新的Activity来实现直播播放,以直播间的id作为MQTT主题。在原先记录直播页面的按钮处,添加JS来启动直播原生的Activity页面,并传递直播间的url和直播间的ID作为参数。
总结
经过了几个月的开发与设计,本画作交流平台基本上开发竣工。本平台的功能完全符合画作的需求和操作习惯。通过本次的设计与开发,本人系统地查阅和习得相关的知识,熟悉了软件开发的全过程。从服务端的PHP设计,再到前端的js代码,UI交互界面的设计。提升了自己全栈的项目开发能力。期间也遇到各种问题。比如SQL的效率和存储空间的优化,npm的Node.js包管理器依赖问题,以及引用的第三方组件兼容冲突和需要的功能缺失。锻炼了实践能力和解决问题的方法,为今后的学习和工作打下了坚实的基础。
由于时间和能力有限,本平台还有一些需要改进的地方。比如本系统还不能很好的支持上千万的用户短时间内突发频繁访问,推荐算法的冷启动和稀疏矩阵处理问题,上传图片产生大量无用的图片Blob类,造成内存泄漏问题,因此目前只能上传低于10000x10000像素的图片。还有关于android、ios和PC端的完全适配跨平台的问题。希望在以后的生活和工作中能够抽空来解决问题和优化代码,使得本平台的功能逐步完善到达商业化的水平。敬请各位教授批评指正!
参考文献:
附 录
订阅的消息投递部分代码查询作品PHP函数classPostEvent:
pass
classNewArtworkEvent(PostEvent):
def_init_(self, author: int, title: str, pid: int) ->None:
“”"
新作品通知
:paramauthor: 源头用户ID
:paramtitle: 作品标题
:parampid: 作品ID
“”"
super().init()
self.author = author
self.title = title
self.pid = pid
classRemoveArtworkEvent(PostEvent):
def_init_(self, pid: int, admin: int = 0, reason: str =None)
->None:
“”"
删除画作通知
:parampid: 图片ID
:paramadmin: 管理员ID
:paramreason: 理由
“”"
super().init()
self.pid = pid
self.admin = admin
self.reason = reason
classCommentEvent(PostEvent):
def_init_(self, pid: int, cid: int, author: int, content: str,
reply: int = 0) ->None:
“”"
评论通知
:parampid: 图片ID
:paramcid: 评论ID
:paramauthor 回复用户ID
:paramcontent: 评论内容
:paramreply: 回复评论ID
“”"
super().init()
self.pid = pid
self.cid = cid
self.author = author
self.content = content
self.reply = reply
classRemoveCommentEvent(PostEvent):
def_init_(self, pid: int, cid: int, author: int, operator: int,
content: str, admin: int = 0,
reason: str =None) ->None:
“”"
删除评论通知
:parampid: 画作ID
:paramcid: 评论iD
:paramcontent 评论内容
:paramadmin: 管理员ID
:paramreason: 理由
“”"
super().init()
self.operator = operator
self.author = author
self.pid = pid
self.cid = cid
self.content = content
self.admin = admin
self.reason = reason
_event_queue = Queue()
classPostEventExecutor(Thread):
def_init_(self, signal: threading.Event):
Thread.init(self)
self.signal = signal
defrun(self):
print(‘投递执行器启动!’)
while True:
event = PostEventManager.pop()
if notevent:
# 挂起
self.signal.wait()
self.signal.clear()
continue
print(‘接收到投递事件:’+ str(vars(event)))
connect = get_connect()
ifisinstance(event, NewArtworkEvent):
# 新作品通知
content = {
’author’: event.author,
’title’: event.title,
’pid’: event.pid
}
content_json = json.dumps(content)
withconnect.cursor()ascursor:
sql =‘’'INSERT INTO messages(type,content,receiver_id,send_datetime)
SELECT %s AS type, %s AS content, follower_id AS receiver_id,
NOW**() AS send_datetime FROM follows WHERE artist_id = %s’’’**
cursor.execute(sql, (‘new_artwork’, content_json, event.author))
mit()
elifisinstance(event, RemoveArtworkEvent):
# 删除作品通知
sql ="SELECT user_id,title FROM pictures WHERE id = %s LIMIT 1"
withconnect.cursor()ascursor:
cursor.execute(sql, (event.pid))
(author, title) = cursor.fetchone()
if notauthor:
print(‘错误:请检查ID=%d的图片是否存在!’% event.pid)
continue
content = {
’pid’: event.pid,
’author’: author,
’title’: title
}
post_type ='remove_artwork’
ifevent.admin != 0:
content[‘admin’] = event.admin
content[‘reason’] = event.reason
# 通知作者
content_json = json.dumps(content)
withconnect.cursor()ascursor:
sql =‘’'INSERT INTO
messages(type,content,receiver_id,send_datetime)
VALUES(%s,%s,%s,NOW())’’'
cursor.execute(sql, (post_type, content_json, author))
# 投递所有关注用户
content[‘relation’] ='follow’
content_json = json.dumps(content)
withconnect.cursor()ascursor:
sql =‘’'INSERT INTO messages(type,content,receiver_id,send_datetime)
SELECT %s AS type, %s AS content, follower_id AS receiver_id,
NOW**() AS send_datetime FROM follows WHERE artist_id = %s’’’**
cursor.execute(sql, (post_type, content_json, author))
# 投递所有收藏作品的用户
connect[‘relation’] ='collect’
content_json = json.dumps(connect)
withconnect.cursor()ascursor:
# 取收藏和关注差集
sql =‘’'INSERT INTO messages(type,content,receiver_id,send_datetime)
SELECT %s AS type, %s AS content, user_id,NOW**() AS send_datetime**
FROM collections LEFT JOIN follows ON follower_id = user_id
WHERE picture_id = %s AND follower_id is NULL’’'
cursor.execute(sql, (post_type, content_json, event.pid))
mit()
elifisinstance(event, CommentEvent):
# 新评论通知
content = {
’pid’: event.pid,
’cid’: event.cid,
’author’: event.author,
’content’: event.content
}
ifevent.reply != 0:
content[‘reply’] = event.reply
content_json = json.dumps(content)
withconnect.cursor()ascursor:
sql ='SELECT user_id FROM pictures WHERE id = %s LIMIT 1’
cursor.execute(sql, (event.pid))
author = (cursor.fetchone())[0]
post_type ='comment’
ifauthor != event.author:
# 通知作者
withconnect.cursor()ascursor:
sql =‘’'INSERT INTO messages(type,content,receiver_id,send_datetime)
VALUES(%s,%s,%s,NOW())’’'
cursor.execute(sql, (post_type, content_json, author))
mit()
ifevent.reply != 0:
# 通知所有的评论回复
withconnect.cursor()ascursor:
sql ='SELECT parent_id_paths FROM replies WHERE id = %s LIMIT 1’
cursor.execute(sql, (event.cid))
data = []
parent_id_paths = (cursor.fetchone())[0]
sql =‘’'INSERT INTO messages(type,content,receiver_id,send_datetime)
SELECT %s AS type, %s AS content, user_id,NOW**() AS send_datetime**
FROM comments WHERE id in (’’’+ parent_id_paths +') AND id != %s’
cursor.execute(sql, (post_type, content_json, author))
mit()
elifisinstance(event, RemoveCommentEvent):
# 删除评论通知
content = {
’pid’: event.pid,
’cid’: event.cid,
’content’: event.content
}
ifevent.admin != 0:
content[‘admin’] = event.admin
content[‘reason’] = event.reason
ifevent.admin != 0orevent.operator != event.author:
content_json = json.dumps(content)
post_type ='remove_comment’
withconnect.cursor()ascursor:
sql =‘’'INSERT INTO messages(type,content,receiver_id,send_datetime)
VALUES(%s,%s,%s,NOW())’’'
cursor.execute(sql, (post_type, content_json))
mit()
# 信号量
singal = threading.Event()
classPostEventManager:
“”"投递消息队列管理器
“”"
def_init_(self):
raiseException(‘该类不能实例化’)
@staticmethod
defpush(event: PostEvent):
_event_queue.put(event)
singal.set()
@staticmethod
defpop() -> Optional[PostEvent]:
if_event_queue.qsize() > 0:
return_event_queue.get()
else:
return None
# 启动推送
post_event_executor = PostEventExecutor(singal)
post_event_executor.start()
作品上传的PHP函数public functiongetList(string $range, ?string $limit =null,
?string $key_word =null, string $sort_field =‘update_datetime’,
?string $sort_type =null, ?array$recommend_picture_id =
null):array
{
$ret = [STATUS=>false];
// 迅搜开启标志
$xsFlag = $key_word && $range ===‘new’&& $sort_field ==
’default’;
$page_number = config(‘myconfig.page_number’);
// 构造查询图片公共语句
$picture_sql = Db::table(‘pictures’)
->where(‘active’,true)
->field(‘pictures.id,title,thumb_height,thumb_paths,update_datetime,collect_num,pictures.user_id’)
->limit($page_number);
if($key_word) {
if($xsFlag) {
// 使用迅搜查询
$picture_id_array = $this->xsSearch($key_word, $limit ? 0 :
(int)$limit);
$picture_sql->where(‘pictures.id’,‘in’, $picture_id_array);
}else{
$picture_sql->join(‘picture_labels’,‘picture_labels.picture_id =
pictures.id’
,‘LEFT’)->join(‘labels’,‘labels.id = picture_labels.label_id’,
’LEFT’);
$key_word_array = split_word($key_word);
$picture_sql->where(‘title’,‘like’, $key_word_array,‘OR’);
$picture_sql->whereOr(‘text’,‘like’, $key_word_array,‘OR’);
$picture_sql->group(‘pictures.id’);
}
}
if($recommend_picture_id) {
$picture_sql->where(‘pictures.id’,‘in’, $recommend_picture_id);
}
// 排序方式
if(!$xsFlag) {
$sort_field = $sort_field ? $sort_field :‘update_time’;
if(!$sort_type) {
switch($sort_field) {
case ‘title’:
$sort_type =’ ASC’;
break;
case ‘update_datetime’:
case ‘collect_datetime’:
case ‘collect_num’:
$sort_type =‘DESC’;
break;
default:
$ret[MSG] =‘错误的排序字段!’;
return$ret;
}
}
$picture_sql->order($sort_field, $sort_type);
if($limit) {
$picture_sql->where($sort_field,
$sort_type ==‘ASC’?‘>’:‘<’,
$limit);
}
}
if($range ==‘new’) {
if($user = User::getUserInfo()) {
// 获取哪些被收藏
$sub_sql = Db::table(‘collections’)
->where(‘collections.user_id’, $user[‘id’])
->field(‘picture_id,collections.user_id’)
->buildSql();
$picture_sql->leftJoin([$sub_sql =>‘collections’],
’collections.picture_id = pictures.id’)
->fieldRaw(‘NOT ISNULL(collections.user_id) AS collected’);
}
}else if($range ==‘collection’|| $range ==‘personal’) {
if($user = User::getUserInfo()) {
if($range ==‘collection’) {
$picture_sql->join(‘collections’,‘collections.picture_id =
pictures.id’
)->where(‘collections.user_id’, $user[‘id’]);
}else{
$picture_sql->where(‘pictures.user_id’, $user[‘id’]);
}
}else{
$ret[MSG] =‘请先登录!’;
return$ret;
}
}else{
$ret[MSG] =‘错误的范围!’;
return$ret;
}
// 开始查询图片
$picture_res = $picture_sql->select();
if($xsFlag) {
// 按照相关度排序
$temp_picture_res = [];
foreach($picture_resas$picture) {
$temp_picture_res[array_search($picture[‘id’], $picture_id_array)] =
$picture;
}
for($i = 0; $i < count($temp_picture_res); $i++) {
$picture_res[$i] = $temp_picture_res[$i];
}
}
$ret[‘pictures’] = $picture_res;
// 获取这些图片的作者信息
$user_id_array = [];
foreach($picture_resas$picture) {
$user_id = $picture[‘user_id’];
if(!in_array($user_id, $user_id_array)) {
array_push($user_id_array, $user_id);
}
}
$ret[‘users’] = Db::table(‘users’)
->field(‘id,icon_paths,nickname’)
->where([‘id’=> $user_id_array])
->select();
$ret[STATUS] =true;
return$ret; }
上传作品前端JS代码public functionupload(string $title, string $details):array
{
$picture_limit_size = config(‘myconfig.picture_limit_size’);
$picture_paths = config(‘myconfig.picture_paths’);
$thumb_limit_size = config(‘myconfig.thumb_limit_size’);
$thumb_path = config(‘myconfig.thumb_paths’);
$labels = input(‘post.labels/a’);
$ret = [STATUS=>false];
$title = trim($title);
if(!Validate::max($title, 36)) {
returnreturn_error(‘标题过长’,‘title’);
}else if(!Validate::min($title, 1)) {
returnreturn_error(‘标题不得为空’,‘title’);
}
if(!Validate::max($details, 65535)) {
returnreturn_error(‘详细文字过长’,‘details’);
}
if($user = User::getUserInfo()) {
// 验证标签
if($labels && count($labels) >= 1 && count($labels) <= 15) {
foreach($labelsas$label) {
if(Validate::max($label, 32)) {
// 存在不合法内容
if($val_res = validate_text($label)) {
returnreturn_error(‘包含敏感词汇:’. $val_res,‘label’);
}
}else{
returnreturn_error(’“’. $label .‘”标签长度过长’,
’label’);
}
}
}else{
returnreturn_error(‘至少必须有一个标签’,‘label’);
}
if(isset($_FILES[‘image’]) &&
isset($_FILES[‘thumb’])) {
foreach([
’title’=> $title,
’details’=> $details
]as$key => $value) {
// 存在不合法内容
if($val_res = validate_text($key)) {
returnreturn_error(‘包含敏感词:’. $val_res, $key);
}
}
$request_files =null;
$val_thumb = validate_image_and_save(‘thumb’, $thumb_limit_size,
$thumb_path,true, $request_files);
if(!$val_thumb[STATUS]) {
$ret = $val_thumb;
$ret[FIELD] =‘thumb’;
return$ret;
}
$request_files = $val_thumb[‘files’];
$val_image = validate_image_and_save(‘image’, $picture_limit_size,
$picture_paths,false, $request_files);
if(!$val_image[STATUS]) {
$ret = $val_image;
$ret[FIELD] =‘image’;
return$ret;
}
// 添加数据到数据库
$pid = Db::table(‘pictures’)
->insertGetId([
’title’=> $title,
’width’=> $val_image[‘width’],
’height’=> $val_image[‘height’],
’thumb_height’=> $val_thumb[‘height’],
’thumb_paths’=> $val_thumb[‘paths’],
’file_paths’=> $val_image[‘paths’],
’update_datetime’=> Db::raw(‘NOW()’),
’details’=> $details,
’user_id’=> $user[‘id’]
]);
// 处理标签
// 当标签库足够大,大到包含所有图片可能的标签的时候,这种方法最优
$res_labels = Db::table(‘labels’)
->where(‘text’,‘in’, $labels)
->field(‘id,text’)
->select();
$res_find_text =function($text)use($res_labels) {
foreach($res_labelsas$res_label) {
if(strcasecmp($res_label[‘text’], $text) == 0) {
return$res_label[‘id’];
}
}
return false;
};
$picture_labels = [];
foreach($labelsas$label) {
$label = trim($label);
if(!($lid = $res_find_text($label))) {
$lid = (Db::query(‘call pr_add_label(:labtxt)’, [‘labtxt’=>
$label]))[0][0][‘lid’];
}
array_push($picture_labels, [‘picture_id’=> $pid,‘label_id’=>
$lid]);
}
Db::table(‘picture_labels’)
->insertAll($picture_labels);
// 添加到迅搜数据库
$xs =new\XS(config(‘myconfig.xunsearch_project_name’));
$data = [
’id’=> $pid,
’title’=> $title,
’update_datetime’=> date(‘Y-m-d H:i:s’),
’details’=> $details,
’nickname’=> $user[‘nickname’],
’labels’=> implode(" ", $labels)
];
$index = $xs->index;
$doc =new\XSDocument($data);
$index->add($doc);
# 推送订阅
$url = config(‘api.postman.base_url’) .‘new_artwork’;
md_http_get($url, [
’author’=> $user[‘id’],
’title’=> $title,
’pid’=> $pid
]);
$ret[STATUS] =true;
$ret[‘pid’] = $pid;
return$ret;
}else{
$ret[MSG] =‘请先上传’;
if(!isset($_FILES[‘image’])) {
$ret[MSG] .=‘图片’;
}
if(!isset($_FILES[‘thumb’])) {
$ret[MSG] .=‘缩略图’;
}
$ret[MSG] .=‘!’;
}
}else{
$ret[MSG] =‘请先登录!’;
}
return$ret;
}
upload () {
let_this =this
_this.progress= 0.0
_this.progressStatus='获取图片中’
Jimp.read(_this.$refs.pictureInput.image)
.then(
(lenna) => {
_this.progressStatus='压缩中’
lenna.resize(256, Jimp.AUTO)
.quality(50)
.getBufferAsync(Jimp.MIME_JPEG)
.then(
(thumb) => {
letblob =newBlob([thumb], { type: Jimp.MIME_JPEG })
_this.progressStatus ='上传中’
letformData =newFormData()
formData.append(‘title’, _this.title)
formData.append(‘details’, _this.details)
formData.append(‘image’,this.$refs.pictureInput.file)
formData.append(‘thumb’, blob,‘thumb.jpg’)
_this.labels.forEach(label => {
formData.append(‘labels[]’, label.text)
})
// formData.append(‘labels’, _this.labels)
this.$ajax.post(
’Artwork/upload’,
formData,
{
timeout: 100000,
// 上传进度事件
onUploadProgress (progressEvent) {
console.log(progressEvent.loaded)
_this.progress = progressEvent.loaded progressEvent.total * 100
}
}
).then(function(resp) {
_this.progressStatus =''
console.log(_this.progressStatus)
console.log(resp)
_this.$nextTick(
() => {
if(!resp.status) {
_this.$vux.toast.show({
text: resp.msg,
type:'warn’
})
}else{
_this.$vux.confirm.show({
title:‘上传成功’,
content:‘点击返回继续上传作品,点击查看跳转到最新作品页面’,
cancelText:‘返回’,
confirmText:‘查看’,
hideOnBlur:true,
onConfirm () {
_this.$router.push({path:‘/’,query: {
enforceUpdate:true} })
},
onCancel () {
_this.reload()
}
}) } } ) })} ) } )
.catch(e => {
console.log(e)
_this.$vux.toast.show({
text: e.message,
type:'warn’
})
})
}
致 谢
首先非常感谢老师开设了这个课题,为本人从事日后的计算机方面的工作提供了宝贵的实践经验和奠定了扎实的理论基础。
本次毕业设计中,首先要感谢许老师悉心的指导。感谢老师在百忙之中,抽取出宝贵的时间,提出了许多的宝贵的意见与经验。本平台的直播功能和相关推荐以及毕业论文都是在老师无微不至的指导和帮助下完成的。老师负责的工作态度和严谨的治学作风,令人深受感动和肃然起敬。同时也要感谢大学四年和蔼可亲的任课老师和周围同舟共济的小伙伴们,使得本人学到了数以万计的知识,为未来的工作和生活打下坚实的基础。
再次感谢所有对本人提供帮助的老师们、同学们!