【畢業設計】基於Vue.js畫作交流平臺的設計與實現

摘要

隨着計算機和網絡革命性地發展,越來越多的畫師使用最新科技工具管理自己的畫作。本畢業設計旨在藉助先進的計算機、快捷的網絡和便利的智能手機來幫助畫師隨時隨地分享自己的畫作。

本畫作交流平臺以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

引言

目前,隨着互聯網的日益進步與革命性的發展,互聯網已經成爲人類生活、工作和學習中不可或缺的存在。它顛覆了傳統的信息傳播方式,無論是形式還是內容、無論是生產方式還是消費方式,都給人類帶來了性的機遇與挑戰。人類已經不能離開網絡,世界已經進入信息化的時代。

智能手機的問世,方便了許多人的日常生活。智能手機就像一個個人電腦一樣,具有獨立的操作系統,可以讓用戶自行安裝軟件、遊戲等第三方服務商提供的程序,通過此類程序來不斷地對手機功能進行擴充,並可以通過移動網絡實現無線網絡接入。智能手機在現代的生活中越來越重要。

隨着2014年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的最新修訂版本,於2014年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是一款開源的關係數據庫管理系統。在2009年的時候,甲骨文公司(Oracle)收購昇陽微系統公司,因此MySQL成爲Oracle旗下產品。

MySQL由於其高性能、低成本、高可靠性的優點,已經成爲了現在最流行的數據庫。MySQL被廣泛地應用在互聯網上的各種中小型網站中。隨着MySQL的不斷成熟和發展,它也逐漸用於更多大規模網站和應用。

關於數據庫設計是整體系統開發中的核心技術。數據庫位於系統的底層、讀寫最頻繁,正確地設計存放數據才能保證數據的正確性、一致性和高效性。

系統實體關係E-R圖

根據本平臺的需求繪製出的本系統實體關係E-R圖如下圖 4‑1所示:

圖 4-1 ‑ 系統實體關係E-R圖

數據庫邏輯模型

根據E-R圖繪製編寫出來的數據庫邏輯模型如下表 4‑1所示。

表 4‑1 邏輯模型表

表名 列名 說明 數據類型 長度 默認值 約束
pictures id 圖片的唯一標識 int unsigned 主鍵 自動遞增 不爲空
title 標題 char 36 不爲空
file_paths 圖片地址 char 56 不爲空
width 寬度 smallint unsigned 不爲空
height 高度 smallint unsigned 不爲空
thumb_height 縮略圖高度 smallint unsigned 不爲空
thumb_paths 縮略圖地址 char 64 不爲空
update_datetime 更新時間 datetime 不爲空
details 詳細 text 65535
collect_num 收藏數 mediumint unsigned 0
active 激活標誌 tinyint 1 TRUE
user_id 用戶的唯一標識 int unsigned 不爲空 users.id外鍵
users id 用戶的唯一標識 int unsigned 主鍵 自動遞增 不爲空
uid 用戶名 char 16 不爲空 唯一
phone_number 手機號 char 11 不爲空 唯一
icon_paths 頭像地址 char 52
role 角色 enum member 在admin member中選擇
member表示普通成員;admin表示管理員
nickname 暱稱 char 16 不爲空 唯一
pwd 密碼 char 128 不爲空
collections picture_id 圖片的唯一標識 int unsigned 不爲空 pictures.id外鍵
user_id 用戶的唯一標識 int unsigned 不爲空 users.id外鍵
collect_datetime 收藏時間 datetime 不爲空
verifications id 驗證唯一標識 int unsigned 主鍵 自動遞增 不爲空
object 對象 char 39 不爲空
IP地址或者爲用戶ID
phone_number 手機號 char 11 不爲空
code 驗證碼 char 6 不爲空
method 方法 varchar 16 不爲空
failure_datetime 失效時間 datetime 不爲空
left_num 剩餘次數 tinyint 5
comments id 評論唯一標識 int unsigned 主鍵 自動遞增 不爲空
content 內容 text 65535 不爲空
comment_datetime 評論時間 datetime 不爲空
user_id 用戶的唯一標識 int unsigned 不爲空 users.id外鍵
picture_id 圖片的唯一標識 int unsigned 不爲空 pictures.id外鍵
active 激活標誌 tinyint 1 TRUE
replies id 回覆唯一標識 int unsigned 主鍵 comments.id外鍵 不爲空
parent_id_paths 回覆父親路徑,逗號隔開 varchar 16382 EMPTY STRING
labels id 標籤唯一標識 int unsigned 主鍵 自動遞增 不爲空
text 標籤文本 char 32 不爲空 唯一
num 使用數 int unsigned 0
picture_labels picture_id 圖片唯一標識 int unsigned 主鍵 不爲空 pictures.id外鍵
label_id 標籤唯一標識 int unsigned 主鍵 不爲空 labels.id外鍵
follows artist_id 關注目標 int unsigned 主鍵 不爲空 user.id外鍵
follower_id 關注者 int unsigned 主鍵 不爲空 user.id外鍵
messages id 消息唯一標誌 int unsigned 主鍵 自動遞增 不爲空
type 類型 char 16 不爲空
content 內容 json 不爲空
send_datetime 發送時間 datetime 不爲空
sender_id 發送者 int unsigned
如果爲空表示爲系統發送
receiver_id 接收者 int unsigned 不爲空
read 已讀標誌 tinyint 1 FALSE
active 激活標誌 tinyint 1 TRUE
footprints user_id 用戶的唯一標識 int unsigned 不爲空 users.id外鍵
picture_id 圖片的唯一標識 int unsigned 不爲空 pictures.id外鍵
browse_datetime 瀏覽時間 datetime 不爲空

觸發器

觸發器用來保證數據完整性的一種方法。它與表的事件相關,其執行不是由程序調用,也不是手工調用,而是由數據觸發。數據庫的觸發器邏輯設計如表
4‑2所示:

表 4‑2 觸發器邏輯設計表

名稱 事件 說明
tr_collections_i collections insert 將對應的pictures表內collec_num自增1
tr_collections_d collections delete 將對應的pictures表內collec_num自減去1
tr_picture_labels_i picture_labels insert 將對應的labels表內num自加1
tr_picture_labels_d picture_labels delete 將對應的labels表內num自減1

存儲過程

存儲過程是一組在大型數據庫系統中爲了完成特定功能的SQL語句集合。用戶可以通過自定義的存儲過程名字和參數來執行他們。存儲過程邏輯設計如表
4‑3所示:

表 4‑3 存儲過程邏輯設計表

名稱 輸入參數 輸出參數 說明
pr_reply_comment 評論本身ID、回覆評論ID 添加評論ID到replies表,計算並存入parent_id_paths
pr_submit_verification 對象、方法、驗證碼、手機號 查詢這個對象上一個驗證碼是否有效,如果有效則結束返回"exist"。
判斷用戶次數是否還有剩餘,如果次數用完,返回"run out",否則次數-1.
正常結束返回"success"。
pr_validate_verification 對象、方法、驗證碼、手機號 判斷對應對象、方法、手機號的驗證碼是否存在,不存在返回"not find"。
判斷驗證碼是否過期,過期返回"out of date"。
判斷驗證碼是否正確,否則返回"wrong"。
修改驗證碼過期時間爲當前時間,使得驗證碼失效,返回"success"。
pr_add_picture_label 圖片ID、標籤 用於添加圖片標籤,避免多個圖片同時上傳添加標籤時候衝突出錯。

事件

事件是用來執行定時任務的一組SQL,到達預訂的時間就會自動觸發。事件邏輯表設計如表
4‑4所示:

表 4‑4 事件邏輯設計表

名稱 說明 計劃
ev_fresh_verification 刪除過期短信驗證碼,並且重置次數 每天凌晨0點整
ev_clear_footprint 刪除用戶三個月前的足跡 每個星期一的0點整

系統實現

本系統服務器端包括數據挖掘服務器、消息投遞服務器以及PHP服務器。其中,數據挖掘使用的數據前期需要事先爬取;PHP服務器主要服務於客戶端各功能模塊。

本系統客戶端包括用戶頁面、最新頁面、新作品頁面、消息頁面、直播頁面等功能模塊。

數據爬取

在做數據挖掘之前,需要大量的數據。本系統選擇日本的虛擬社區PIXIV進行數據爬取。爬取主要分成四大部分:爬蟲數據庫、PIXIV爬取、掃描添加用戶和及其作品、掃描用戶之間關係。

爬蟲數據庫

由於需要爬取的數據量巨大,因此建立本地和PIXIV網站的數據映射,採用SQLite作爲爬蟲使用的本地數據庫。

根據爬蟲的需求分析,繪製編寫出來的數據庫邏輯模型如下表 5‑1所示:

表 5‑1 數據庫邏輯模型

表名 列名 說明 數據類型 約束
users origin 用戶在PIXIV上原始ID integer 主鍵 不爲空
target 用戶存儲在MySQL中ID integer
pictures origin 圖片在PIXIV上原始ID integer 主鍵 不爲空
target 圖片存儲在MySQL中ID integer

PIXIV爬取

PIXIV爬取使用python3的pixivpy3庫。

由於pixivpy3不支持等待設置,爬取的時候可能會被PIXIV識別並攔截,因此需要對AppPixivAPI.requests_call打上猴子補丁。補丁代碼如下:

old_requests_call = AppPixivAPI.requests_call
def requests_call(self, method, url, headers={}, params=None,
data=None, stream=False):
sleep(0.5)
return old_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
def get_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
def get_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 (i in 1: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的候選項集,去掉不滿足最小支持度的項,得到長度爲1的頻繁項集。

  2. 在上一次迭代長度爲k-1的頻繁項集上,產生k的候選集。

  3. 在長度爲k的候選集中,除去k-1的非頻繁項集的候選集。

  4. 在當前獲取的長度爲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 (i in 1: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 新作品通知

字段名 數據類型 默認值 描 述
author int 源頭用戶ID
title string 作品標題
pid int 作品ID

表 5‑3 作品刪除通知

字段名 數據類型 默認值 描 述
pid int 圖片ID
admin int 0 管理員ID 如果是管理員刪除的,需填寫該字段。
reason string null 理由 如果是管理員刪除的,需填寫該字段。

表 5‑4 新評論通知

字段名 數據類型 默認值 描 述
pid int 圖片ID
cid int 評論ID
author int 回覆用戶ID
content string 評論內容,建議截斷
reply int 0 回覆評論ID

表 5‑5 刪除評論通知

字段名 數據類型 默認值 描 述
pid int 圖片ID
cid int 評論ID
author int 評論發佈者ID
operator int 操作者
content string 評論內容,建議截斷
admin int 0 管理員ID。如果是管理員刪除的需填寫該字段。
reason string null 理由。如果是管理員刪除的,需填寫該字段。

投遞消息事件處理

每個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) => {
let pwdString = value
for (let i = 0; i < 5; i++) {
pwdString = salt + pwdString + salt
let sha = new SHA(‘SHA3-512’, ‘TEXT’)
sha.update(pwdString)
pwdString = sha.getHash(‘HEX’)
}
return pwdString
}

賬號和密碼發送到

地址中。其中index.php爲ThinkPHP的入口文件,index爲模塊名,User爲控制器類名稱,login爲其中函數。服務器先判斷用戶名是否存在。如果存在則保存在Session內以便以後請求使用。

static private function saveUserInfo(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) {
let translation = {
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 function register(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 {
let userInfo = _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 () {
let viewBox = this.viewBox()
let height = viewBox.getScrollBody().offsetHeight
height -= document.getElementById(‘sticky’).clientHeight
return height
},
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 function getDetails(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;}

之後,客戶端對獲取到的評論和回覆評論處理,如果回覆的評論中包含已經被刪除的評論,則顯示“該條的評論已被刪除!”。具體處理的部分代碼如下所示:

let replies = resp[‘replies’]
if (replies) {
for (let i = 0; i < replies.length; i++) {
replies[i][‘content’] = _this.$decodeText(replies[i][‘content’])
_this.replies[replies[i][‘id’]] = replies[i] }}
let comments = resp[‘comments’]
if (comments.length < _this.$store.state.pageNumber)
{
_this.isEnd = true
}
if (comments) {
for (let i = 0; i < comments.length; i++) {
comments[i][‘content’] = _this.$decodeText(comments[i][‘content’])
if (comments[i][‘parent_id_paths’]) {
let parentReplies = comments[i][‘parent_id_paths’].split(’,’)
comments[i][‘replies’] = []
for (let j = 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跨域攻擊。實現代碼如下:

return text.replace(’&’, ‘&amp;’)
.replace(’<’, ‘&lt;’)
.replace(’>’, ‘&gt;’)
.replace(’"’, ‘&quot;’)
.replace(’\’’, ‘&#x27;’)
.replace(’/’, ‘&#x2F;’)
.replace(/#####emoji:(.*?)#####/g, ‘<img
src=\’/static/img/$1\’/>’
)

服務器返回的日期基本上都是形式如同"2019-03-14
22:07:05"這樣生硬的時間字符串,不能給用戶友好的體驗感。對時間字符串使用友好處理,使其顯示爲用戶可以良好體驗感受的時間,比如“1分鐘前”、“1小時前”、“1天前”等這樣的字符串。對此功能封裝爲一個全局函數,掛載在vue到實例中,函數代碼如下:

Vue.prototype.$getFriendlyTime = function (str) {
let currentTime = new Date()
let arr = str.split(/\s+/gi)
let arr1, arr2, oldTime, delta
let getIntValue = function (ss, defaultValue) {
try {
return parseInt(ss, 10) ? parseInt(ss, 10) : defaultValue
} catch (e) {
return defaultValue
} }
let getWidthString = function (num) {
return num < 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)(https://i.loli.net/2020/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 function labelComplete(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) {
let title = this.$refs.pictureInput.file.name
this.title = title.substring(0, title.indexOf(’.’))
}}

新作品的頁面如圖 5‑14所示:

圖 5-14 ‑ 新作品頁面

上傳時候使用Jimp庫對圖片壓縮。Jimp是一個包含各種插件的JS圖片庫,由於這裏只需要讀取並且修改圖片尺寸,因此只讀取這兩個插件。具體自定義代碼如下:

import configure from ‘@jimp/custom’
import types from ‘@jimp/types’
import resize from ‘@jimp/plugin-resize’
export default configure({
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(() => {
let viewBox = this.viewBox()
let scrollBody = viewBox.getScrollBody()
let scrollTopx = scrollBody.scrollHeight -
scrollBody.clientHeight
viewBox.scrollTo(scrollTopx)
})
}, 1000)}

具體的聊天的內容和消息發送框同評論的實現大致相同。頁面效果如圖 5‑17所示:

圖 5-17 ‑ 聊天頁面

直播頁面的實現

直播使用vue-video-player。直播分推流和播流兩個部分。由於阿里雲默認開啓URL鑑權,並且不可關閉。URL鑑權功能的目的是保護用戶站點的資源不被非法下載盜用。如果採用防盜鏈方法添加referer,可以解決部分盜鏈問題。但由於referer內容可以僞造,因此這種方法並不能保護站點。

鑑權URL由對應的地址+驗證串組合而成。驗證串=鑑權key+失效時間通過MD5計算出。具體計算方法見代碼:

private function getUrl(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://btv.ngmks.com’;
break;
case ‘flv’:
$base_url = ‘http://btv.ngmks.com’;
$stream_name .= ‘.flv’;
break;
case ‘hls’:
$base_url = ‘http://btv.ngmks.com’;
$stream_name .= ‘.m3u8’; }
} else {
$base_url = ‘rtmp://ptv.ngmks.com’;
}
$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服務器。服務器端代碼如下所示:

package main
import (
"fmt"
"github.com/surgemq/surgemq/service"
)
func main() {
// 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端的完全適配跨平臺的問題。希望在以後的生活和工作中能夠抽空來解決問題和優化代碼,使得本平臺的功能逐步完善到達商業化的水平。敬請各位教授批評指正!

參考文獻:

附 錄

  1. 訂閱的消息投遞部分代碼

class PostEvent:
pass
class NewArtworkEvent(PostEvent):
def _init_(self, author: int, title: str, pid: int) -> None:
“”"
新作品通知
:param author: 源頭用戶ID
:param title: 作品標題
:param pid: 作品ID
“”"
super().init()
self.author = author
self.title = title
self.pid = pid
class RemoveArtworkEvent(PostEvent):
def _init_(self, pid: int, admin: int = 0, reason: str = None)
-> None:
“”"
刪除畫作通知
:param pid: 圖片ID
:param admin: 管理員ID
:param reason: 理由
“”"
super().init()
self.pid = pid
self.admin = admin
self.reason = reason
class CommentEvent(PostEvent):
def _init_(self, pid: int, cid: int, author: int, content: str,
reply: int = 0) -> None:
“”"
評論通知
:param pid: 圖片ID
:param cid: 評論ID
:param author 回覆用戶ID
:param content: 評論內容
:param reply: 回覆評論ID
“”"
super().init()
self.pid = pid
self.cid = cid
self.author = author
self.content = content
self.reply = reply
class RemoveCommentEvent(PostEvent):
def _init_(self, pid: int, cid: int, author: int, operator: int,
content: str, admin: int = 0,
reason: str = None) -> None:
“”"
刪除評論通知
:param pid: 畫作ID
:param cid: 評論iD
:param content 評論內容
:param admin: 管理員ID
:param reason: 理由
“”"
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()
class PostEventExecutor(Thread):
def _init_(self, signal: threading.Event):
Thread.init(self)
self.signal = signal
def run(self):
print(‘投遞執行器啓動!’)
while True:
event = PostEventManager.pop()
if not event:
# 掛起
self.signal.wait()
self.signal.clear()
continue
print(‘接收到投遞事件:’ + str(vars(event)))
connect = get_connect()
if isinstance(event, NewArtworkEvent):
# 新作品通知
content = {
’author’: event.author,
’title’: event.title,
’pid’: event.pid
}
content_json = json.dumps(content)
with connect.cursor() as cursor:
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))
connect.commit()
elif isinstance(event, RemoveArtworkEvent):
# 刪除作品通知
sql = "SELECT user_id,title FROM pictures WHERE id = %s LIMIT 1"
with connect.cursor() as cursor:
cursor.execute(sql, (event.pid))
(author, title) = cursor.fetchone()
if not author:
print(‘錯誤:請檢查ID=%d的圖片是否存在!’ % event.pid)
continue
content = {
’pid’: event.pid,
’author’: author,
’title’: title
}
post_type = 'remove_artwork’
if event.admin != 0:
content[‘admin’] = event.admin
content[‘reason’] = event.reason
# 通知作者
content_json = json.dumps(content)
with connect.cursor() as cursor:
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)
with connect.cursor() as cursor:
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)
with connect.cursor() as cursor:
# 取收藏和關注差集
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))
connect.commit()
elif isinstance(event, CommentEvent):
# 新評論通知
content = {
’pid’: event.pid,
’cid’: event.cid,
’author’: event.author,
’content’: event.content
}
if event.reply != 0:
content[‘reply’] = event.reply
content_json = json.dumps(content)
with connect.cursor() as cursor:
sql = 'SELECT user_id FROM pictures WHERE id = %s LIMIT 1’
cursor.execute(sql, (event.pid))
author = (cursor.fetchone())[0]
post_type = 'comment’
if author != event.author:
# 通知作者
with connect.cursor() as cursor:
sql = ‘’'INSERT INTO messages(type,content,receiver_id,send_datetime)
VALUES(%s,%s,%s,NOW())’’'
cursor.execute(sql, (post_type, content_json, author))
connect.commit()
if event.reply != 0:
# 通知所有的評論回覆
with connect.cursor() as cursor:
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))
connect.commit()
elif isinstance(event, RemoveCommentEvent):
# 刪除評論通知
content = {
’pid’: event.pid,
’cid’: event.cid,
’content’: event.content
}
if event.admin != 0:
content[‘admin’] = event.admin
content[‘reason’] = event.reason
if event.admin != 0 or event.operator != event.author:
content_json = json.dumps(content)
post_type = 'remove_comment’
with connect.cursor() as cursor:
sql = ‘’'INSERT INTO messages(type,content,receiver_id,send_datetime)
VALUES(%s,%s,%s,NOW())’’'
cursor.execute(sql, (post_type, content_json))
connect.commit()
# 信號量
singal = threading.Event()
class PostEventManager:
“”"投遞消息隊列管理器
“”"
def _init_(self):
raise Exception(‘該類不能實例化’)
@staticmethod
def push(event: PostEvent):
_event_queue.put(event)
singal.set()
@staticmethod
def pop() -> Optional[PostEvent]:
if _event_queue.qsize() > 0:
return _event_queue.get()
else:
return None
# 啓動推送
post_event_executor = PostEventExecutor(singal)
post_event_executor.start()

  1. 查詢作品PHP函數

public function getList(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_res as $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_res as $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; }

  1. 作品上傳的PHP函數

public function upload(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)) {
return return_error(‘標題過長’, ‘title’);
} else if (!Validate::min($title, 1)) {
return return_error(‘標題不得爲空’, ‘title’);
}
if (!Validate::max($details, 65535)) {
return return_error(‘詳細文字過長’, ‘details’);
}
if ($user = User::getUserInfo()) {
// 驗證標籤
if ($labels && count($labels) >= 1 && count($labels) <= 15) {
foreach ($labels as $label) {
if (Validate::max($label, 32)) {
// 存在不合法內容
if ($val_res = validate_text($label)) {
return return_error(‘包含敏感詞彙:’ . $val_res, ‘label’);
}
} else {
return return_error(’“’ . $label . ‘”標籤長度過長’,
’label’);
}
}
} else {
return return_error(‘至少必須有一個標籤’, ‘label’);
}
if (isset($_FILES[‘image’]) &&
isset($_FILES[‘thumb’])) {
foreach ([
’title’ => $title,
’details’ => $details
] as $key => $value) {
// 存在不合法內容
if ($val_res = validate_text($key)) {
return return_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_labels as $res_label) {
if (strcasecmp($res_label[‘text’], $text) == 0) {
return $res_label[‘id’];
}
}
return false;
};
$picture_labels = [];
foreach ($labels as $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;
}

  1. 上傳作品前端JS代碼

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) => {
let blob = new Blob([thumb], { type: Jimp.MIME_JPEG })
_this.progressStatus = '上傳中’
let formData = new FormData()
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’
})
})
}

致 謝

首先非常感謝老師開設了這個課題,爲本人從事日後的計算機方面的工作提供了寶貴的實踐經驗和奠定了紮實的理論基礎。

本次畢業設計中,首先要感謝許老師悉心的指導。感謝老師在百忙之中,抽取出寶貴的時間,提出了許多的寶貴的意見與經驗。本平臺的直播功能和相關推薦以及畢業論文都是在老師無微不至的指導和幫助下完成的。老師負責的工作態度和嚴謹的治學作風,令人深受感動和肅然起敬。同時也要感謝大學四年和藹可親的任課老師和周圍同舟共濟的小夥伴們,使得本人學到了數以萬計的知識,爲未來的工作和生活打下堅實的基礎。

再次感謝所有對本人提供幫助的老師們、同學們!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章