参考文章:

  1. 手撸 Electron 自动更新,再繁琐也要搞懂它 - 掘金 (juejin.cn)
  2. Electron 自动更新,绕过 latest.yml 使用自定义接口 - 掘金 (juejin.cn)
  3. electron-updater实现热更新完整流程 - 掘金 (juejin.cn)

一、 配置

1. 安装electron-updater

npm install electron-updater

2. 配置electron-builder

修改electron-builder.json,增加以下内容

"publish": [
    {
        "provider": "generic",
        "url": "你的更新地址,可以是oss"
    }
]

二、具体操作

1. 代码部分

在主进程文件夹创建update.ts文件:

import { autoUpdater } from "electron-updater";
import { BrowserWindow, ipcMain, app } from 'electron'
import log from "electron-log" // 如果没有自己封装
import path from "path"

let mainWindow: BrowserWindow
// 封装更新相关的进程通信方法
const sendUpdateMessage = (opt: { cmd: string, data: any }) => {
    mainWindow.webContents.send(opt.cmd, opt.data)
}
export const checkUpdate = (win: BrowserWindow) => {
    mainWindow = win 
    // 关闭自动更新
    autoUpdater.autoDownload = false  
    // 开启开发环境调试,后边会有说明
    autoUpdater.forceDevUpdateConfig = true
    // 日志记录设置
    log.transports.file.level = "debug"
    autoUpdater.logger = log
    // 开发环境下的配置,详细参考后续说明
    // 方法3:以下整个if都不需要
    // if(!app.isPackaged){
    //   // 方法1: 项目根目录创建dev-update.yml
    //     autoUpdater.updateConfigPath = path.join(__dirname, "../dev-update.yml")
    //   // 方法2:
    //     autoUpdater.setFeedURL('http://127.0.0.1:5173/release')
    // }
    // 监听升级失败事件
    autoUpdater.on("error", (error) => {
        sendUpdateMessage({
            cmd: 'update-error',
            data: error
        })
    })
    //监听发现可用更新事件
    autoUpdater.on('update-available', (message) => {
        sendUpdateMessage({
            cmd: 'update-available',
            data: message
        })
    })
    //监听没有可用更新事件
    autoUpdater.on('update-not-available', (message) => {
        sendUpdateMessage({
            cmd: 'update-not-available',
            data: message
        })
    });

    // 更新下载进度事件
    autoUpdater.on('download-progress', (progressObj) => {
        sendUpdateMessage({
            cmd: 'download-progress',
            data: progressObj
        })
    });
    //监听下载完成事件
    autoUpdater.on('update-downloaded',(releaseObj) => {
        sendUpdateMessage({
            cmd: 'update-downloaded',
            data: releaseObj
        })
        //退出并安装更新包
        autoUpdater.quitAndInstall()
    });
    
    //接收渲染进程消息,开始检查更新
    ipcMain.on("check-update", (event) => {
        //执行自动更新检查
        console.log("检查更新")
        autoUpdater.checkForUpdates()
    })

    // 触发更新
    ipcMain.on("update-app",(event) => {
        autoUpdater.downloadUpdate()
    })
}

在main.ts中配置如下:

...
import { checkUpdate } from './update'
...

const win = new BrowserWindow({
    ...
})
try {
    checkUpdate(win)
} catch (error) {
    logger.error(error)
}

我这里h5部分采用的vue,对应的使用如下:

<script lang="ts" setup>
// 百分比进度
const progress = ref("")
const init = () => {
    // 这里对ipcRenderer进行了简单封装,参考ipc.ts
    ipc.checkUpdate()
    ipc.on("update-available",(event:any, msg:any)=>{
        // 有更新
    })
    ipc.on("update-not-available",(event:any, msg:any)=>{
        // 没有更新
    })
    ipc.on("download-progress",(event:any, msg:any)=>{
        // 下载更新进度
        progress.value = `${Math.ceil(msg.percent)}%`
    })
    ipc.on("update-downloaded",(event:any, msg:any)=>{
        // 更新下载完成
    })
    ipc.on("update-error", (event:any, msg:any)=>{
        // 更新失败
        logger.log("update-error")
        logger.log(msg)
    })
}
const confrimUpdate = () => {
    ipc.updateApp()
}
</script>

封装的ipc.ts的部分内容:

/**
 * 监听主进程通信
 * @param title 
 * @param callback 
 */
on(title: string, callback: Function){
    ipcRenderer.on(title, callback)
}
// 软件更新相关
/**
 * 检查更新
 */
checkUpdate = () => {
    ipcRenderer.send('check-update')
}
/**
 * 执行更新
 */
updateApp = () => {
    ipcRenderer.send('update-app')
}

2. 打包

项目打包后会在指定的打包目录下(我这里是release文件夹)生成对应exe安装包和latest.yml文件,将这两个文件上传到最开始配置的url对应的路径内

三、关于开发环境下调试配置

一般情况下开发环境下直接调试会报错Skip checkForUpdates because application is not packed and dev update config is not forced,包括参考文章在内的绝大部分文章关于这块的解决方案是将app.isPackaged改为true:

if (!app.isPackaged) { 
    Object.defineProperty(app, 'isPackaged', { 
        get: () => true, 
    }); 
}

这样确实可以做到开发环境中调试,但是可能会影响到其他地方的功能配置,可以在update.ts中配置以下代码,并且不需要修改app.isPackaged:

autoUpdater.forceDevUpdateConfig = true

以下为调试方法<br/>

方法1:<br/>

  1. 在根目录创建一个yml文件,名称自定义,这里我的是dev-update.yml,内容如下

    provider: generic
    updaterCacheDirName: dev-updater
    url: "http://127.0.0.1:5173/release"

    说明:<br/>
    updaterCacheDirName 定义下载目录,全路径是C:\Users\用户名\AppData\Local\dev-updater,不配置则在C:\Users\用户名\AppData\Local下自动创建文件夹,开发环境下为项目名,生产环境下为项目名-updater<br/>
    url 更新地址,指向latest.yml。不配置有时候会报错,最好配置上

  2. 上文update.ts 文件中对应位置填写如下

    autoUpdater.updateConfigPath = path.join(__dirname, "../dev-update.yml")

    然后就可以正常调试了。

方法2:<br/>
复制latest.yml到项目根目录,并改名为dev-app-update.yml,<br/>
在update.ts中使用以下处理:

// 设置检查更新的地址为本地打包生成的latest.yml所在文件夹
autoUpdater.setFeedURL('http://127.0.0.1:5173/release')

方法3:<br/>
项目根目录创建dev-app-update.yml并填写以下内容,不需要使用autoUpdater.setFeedURL也不需要配置autoUpdater.updateConfigPath:

provider: generic
updaterCacheDirName: dev-updater
url: "http://127.0.0.1:5173/release"

以上方法中也可以设置为http://127.0.0.1:5173,即项目根目录,此时需要将latest.yml和打包生成的exe文件复制到根目录,这里如果不复制exe文件会报404错误

记录一下踩过的坑吧。
项目里对接微信特约商户支付差不多是一年前了吧,很多东西都忘了,刚好今天在新项目里调试这一块,然后掉坑里了。
比较久的一些坑以后再说,就先记录一下今天的坑吧。
这里以php,微信最新的sdk为例。

先说说第一个坑。

微信支付开发文档(境内)分普通商户版、服务商版、银行服务商版,这里只讨论前两个,两者区别的话就是在接口上的一些参数不同,比如服务商版在调用统一支付API的时候需要填写sub_app_id,sub_mch_id之类的,这没什么,加上就是了,如果自己从0开始写的话那没什么,如果使用微信的SDK的话就要注意了,普通商户版和服务商版提供的SDK同样都是普通商户版的SDK,这就意味着,如果你要对接服务商版,缺失的参数以及对应的设置方法获取方法需要你自己在SDK里添加好,否则不成功,比如前面提到的sub_app_id,sub_mch_id。

贴上一段代码:

public static function unifiedOrder($config, $inputObj, $timeOut = 6)
{
    $url = "https://api.mch.weixin.qq.com/pay/unifiedorder";
    //检测必填参数
    if(!$inputObj->IsOut_trade_noSet()) {
        throw new WxPayException("缺少统一支付接口必填参数out_trade_no!");
    }else if(!$inputObj->IsBodySet()){
        throw new WxPayException("缺少统一支付接口必填参数body!");
    }else if(!$inputObj->IsTotal_feeSet()) {
        throw new WxPayException("缺少统一支付接口必填参数total_fee!");
    }else if(!$inputObj->IsTrade_typeSet()) {
        throw new WxPayException("缺少统一支付接口必填参数trade_type!");
    }
    
    //关联参数
    if($inputObj->GetTrade_type() == "JSAPI" && !$inputObj->IsOpenidSet()){
        throw new WxPayException("统一支付接口中,缺少必填参数openid!trade_type为JSAPI时,openid为必填参数!");
    }
    if($inputObj->GetTrade_type() == "NATIVE" && !$inputObj->IsProduct_idSet()){
        throw new WxPayException("统一支付接口中,缺少必填参数product_id!trade_type为JSAPI时,product_id为必填参数!");
    }
    
    //异步通知url未设置,则使用配置文件中的url
    if(!$inputObj->IsNotify_urlSet() && $config->GetNotifyUrl() != ""){
        $inputObj->SetNotify_url($config->GetNotifyUrl());//异步通知url
    }
    
    $inputObj->SetAppid($config->GetAppId());//公众账号ID
    $inputObj->SetMch_id($config->GetMerchantId());//商户号
    $inputObj->SetSpbill_create_ip($_SERVER['REMOTE_ADDR']);//终端ip               
    $inputObj->SetNonce_str(self::getNonceStr());//随机字符串
    
    //签名
    $inputObj->SetSign($config);
    $xml = $inputObj->ToXml();
    
    $startTimeStamp = self::getMillisecond();//请求开始时间
    $response = self::postXmlCurl($config, $xml, $url, false, $timeOut);
    $result = WxPayResults::Init($config, $response);
    self::reportCostTime($config, $url, $startTimeStamp, $result);//上报请求花费时间
    
    return $result;
}

这是微信支付SDK中统一下单接口方法,其中$inputObj在普通商户下这样用是没问题的,但是到了服务商版本,有了如下区别:
先来看看服务商支付所需要的参数(摘自微信支付服务商版统一支付API):

字段名变量名必填类型示例值描述
服务商的APPIDappid是String(32)wx8888888888888888服务商商户的APPID
商户号mch_id是String(32)1900000109微信支付分配的商户号
子商户公众账号IDsub_appid否String(32)wx8888888888888888微信分配的子商户公众账号ID,如需在支付完成后获取sub_openid则此参数必传。
子商户号sub_mch_id是String(32)1900000109微信支付分配的子商户号
设备号device_info否String(32)013467007045764终端设备号(门店号或收银设备ID),注意:PC网页或JSAPI支付请传"WEB"
开发票入口开放标识receipt否String(8)YY,传入Y时,支付成功消息和支付详情页将出现开票入口。需要在微信支付商户平台或微信公众平台开通电子发票功能,传此字段才可生效
随机字符串nonce_str是String(32)5K8264ILTKCH16CQ2502SI8ZNMTM67VS随机字符串,不长于32位。推荐随机数生成算法
签名sign是String(64)C380BEC2BFD727A4B6845133519F3AD6签名,详见签名生成算法
签名类型sign_type否String(32)HMAC-SHA256签名类型,目前支持HMAC-SHA256和MD5,默认为MD5
商品描述body是String(128)腾讯充值中心-QQ会员充值商品描述交易字段格式根据不同的应用场景建议按照以下格式上传:
(1)PC网站——传入浏览器打开的网站主页title名-实际商品名称,例如:腾讯充值中心-QQ会员充值;
(2) 公众号——传入公众号名称-实际商品名称,例如:腾讯形象店- image-QQ公仔;
(3) H5——应用在浏览器网页上的场景,传入浏览器打开的移动网页的主页title名-实际商品名称,例如:腾讯充值中心-QQ会员充值;
(4) 线下门店——门店品牌名-城市分店名-实际商品名称,例如: image形象店-深圳腾大- QQ公仔)
(5) APP——需传入应用市场上的APP名字-实际商品名称,天天爱消除-游戏充值。
商品详情detail否String(6000)商品详情描述商品详细描述,对于使用单品优惠的商户,改字段必须按照规范上传,详见“单品优惠参数说明”
附加数据attach否String(127)说明附加数据,在查询API和支付通知中原样返回,该字段主要用于商户携带订单的自定义数据
商户订单号out_trade_no是String(32)1217752501201407033233368018商户系统内部订单号,要求32个字符内,只能是数字、大小写字母_-|*且在同一个商户号下唯一。详见商户订单号
货币类型fee_type否String(16)CNY符合ISO 4217标准的三位字母代码,默认人民币:CNY,其他值列表详见货币类型
总金额total_fee是Int888订单总金额,只能为整数,详见支付金额
终端IPspbill_create_ip是String(64)123.12.12.123支持IPV4和IPV6两种格式的IP地址。调用微信支付API的机器IP
交易起始时间time_start否String(14)20091225091010订单生成时间,格式为yyyyMMddHHmmss,如2009年12月25日9点10分10秒表示为20091225091010。其他详见时间规则
交易结束时间time_expire否String(14)20091227091010订单失效时间,格式为yyyyMMddHHmmss,如2009年12月27日9点10分10秒表示为20091227091010。订单失效时间是针对订单号而言的,由于在请求支付的时候有一个必传参数prepay_id只有两小时的有效期,所以在重入时间超过2小时的时候需要重新请求下单接口获取新的prepay_id。其他详见时间规则
time_expire只能第一次下单传值,不允许二次修改,二次修改系统将报错。如用户支付失败后,需再次支付,需更换原订单号重新下单。
建议:最短失效时间间隔大于1分钟
订单优惠标记goods_tag否String(32)WXG订单优惠标记,代金券或立减优惠功能的参数,说明详见代金券或立减优惠
通知地址notify_url是String(256)http://www.weixin.qq.com/wxpay/pay.php接收微信支付异步通知回调地址,通知url必须为直接可访问的url,不能携带参数。
交易类型trade_type是String(16)JSAPIJSAPI -JSAPI支付

NATIVE -Native支付

APP -APP支付

说明详见参数规定
商品IDproduct_id否String(32)12235413214070356458058trade_type=NATIVE时,此参数必传。此id为二维码中包含的商品ID,商户自行定义。
指定支付方式limit_pay否String(32)no_creditno_credit--指定不能使用信用卡支付
用户标识openid否String(128)oUpF8uMuAJO_M2pxb1Q9zNjWeS6otrade_type=JSAPI,此参数必传,用户在主商户appid下的唯一标识。openid和sub_openid可以选传其中之一,如果选择传sub_openid,则必须传sub_appid。下单前需要调用【网页授权获取用户信息】接口获取到用户的Openid。
用户子标识sub_openid否String(128)oUpF8uMuAJO_M2pxb1Q9zNjWeS6otrade_type=JSAPI,此参数必传,用户在子商户appid下的唯一标识。openid和sub_openid可以选传其中之一,如果选择传sub_openid,则必须传sub_appid。下单前需要调用【网页授权获取用户信息】接口获取到用户的Openid。
电子发票入口开放标识receipt否String(8)YY,传入Y时,支付成功消息和支付详情页将出现开票入口。需要在微信支付商户平台或微信公众平台开通电子发票功能,传此字段才可生效
+场景信息scene_info否String(256){"store_info" : {"id": "SZTX001","name": "腾大餐厅","area_code": "440305","address": "科技园中一路腾讯大厦" }}该字段常用于线下活动时的场景信息上报,支持上报实际门店信息,商户也可以按需求自己上报相关信息。该字段为JSON对象数据,对象格式为{"store_info":{"id": "门店ID","name": "名称","area_code": "编码","address": "地址" }} ,字段详细说明请点击行前的+展开

再来看看普通商户所需要的参数(摘自微信支付普通商户版统一支付API):

字段名变量名必填类型示例值描述
公众账号IDappid是String(32)wxd678efh567hg6787微信支付分配的公众账号ID(企业号corpid即为此appId)
商户号mch_id是String(32)1230000109微信支付分配的商户号
设备号device_info否String(32)013467007045764自定义参数,可以为终端设备号(门店号或收银设备ID),PC网页或公众号内支付可以传"WEB"
随机字符串nonce_str是String(32)5K8264ILTKCH16CQ2502SI8ZNMTM67VS随机字符串,长度要求在32位以内。推荐随机数生成算法
签名sign是String(32)C380BEC2BFD727A4B6845133519F3AD6通过签名算法计算得出的签名值,详见签名生成算法
签名类型sign_type否String(32)MD5签名类型,默认为MD5,支持HMAC-SHA256和MD5。
商品描述body是String(128)腾讯充值中心-QQ会员充值商品简单描述,该字段请按照规范传递,具体请见参数规定
商品详情detail否String(6000)商品描述商品详细描述,对于使用单品优惠的商户,该字段必须按照规范上传,详见“单品优惠参数说明”
附加数据attach否String(127)深圳分店附加数据,在查询API和支付通知中原样返回,可作为自定义参数使用。
商户订单号out_trade_no是String(32)20150806125346商户系统内部订单号,要求32个字符内,只能是数字、大小写字母_-|* 且在同一个商户号下唯一。详见商户订单号
标价币种fee_type否String(16)CNY符合ISO 4217标准的三位字母代码,默认人民币:CNY,详细列表请参见货币类型
标价金额total_fee是Int88订单总金额,单位为分,详见支付金额
终端IPspbill_create_ip是String(64)123.12.12.123支持IPV4和IPV6两种格式的IP地址。用户的客户端IP
交易起始时间time_start否String(14)20091225091010订单生成时间,格式为yyyyMMddHHmmss,如2009年12月25日9点10分10秒表示为20091225091010。其他详见时间规则
交易结束时间time_expire否String(14)20091227091010订单失效时间,格式为yyyyMMddHHmmss,如2009年12月27日9点10分10秒表示为20091227091010。订单失效时间是针对订单号而言的,由于在请求支付的时候有一个必传参数prepay_id只有两小时的有效期,所以在重入时间超过2小时的时候需要重新请求下单接口获取新的prepay_id。其他详见时间规则
time_expire只能第一次下单传值,不允许二次修改,二次修改系统将报错。如用户支付失败后,需再次支付,需更换原订单号重新下单。
建议:最短失效时间间隔大于1分钟
订单优惠标记goods_tag否String(32)WXG订单优惠标记,使用代金券或立减优惠功能时需要的参数,说明详见代金券或立减优惠
通知地址notify_url是String(256)http://www.weixin.qq.com/wxpay/pay.php异步接收微信支付结果通知的回调地址,通知url必须为外网可访问的url,不能携带参数。
交易类型trade_type是String(16)JSAPIJSAPI -JSAPI支付
NATIVE -Native支付
APP -APP支付
说明详见参数规定
商品IDproduct_id否String(32)12235413214070356458058trade_type=NATIVE时,此参数必传。此参数为二维码中包含的商品ID,商户自行定义。
指定支付方式limit_pay否String(32)no_credit上传此参数no_credit--可限制用户不能使用信用卡支付
用户标识openid否String(128)oUpF8uMuAJO_M2pxb1Q9zNjWeS6otrade_type=JSAPI时(即JSAPI支付),此参数必传,此参数为微信用户在商户对应appid下的唯一标识。openid如何获取,可参考【获取openid】。企业号请使用【企业号OAuth2.0接口】获取企业号内成员userid,再调用【企业号userid转openid接口】进行转换
电子发票入口开放标识receipt否String(8)YY,传入Y时,支付成功消息和支付详情页将出现开票入口。需要在微信支付商户平台或微信公众平台开通电子发票功能,传此字段才可生效
+场景信息scene_info否String(256){"store_info" : {"id": "SZTX001","name": "腾大餐厅","area_code": "440305","address": "科技园中一路腾讯大厦" }}该字段常用于线下活动时的场景信息上报,支持上报实际门店信息,商户也可以按需求自己上报相关信息。该字段为JSON对象数据,对象格式为{"store_info":{"id": "门店ID","name": "名称","area_code": "编码","address": "地址" }} ,字段详细说明请点击行前的+展开

这里就很明显看得出来二者的区别,服务商支付需要服务商的appid和服务商的mch_id,然后多了sub_app_id、sub_mch_id、sub_openid。

按照SDK的写法,这三个值应该有对应的设置方法和读取方法:

$inputObj->SetSubAppid();
$inputObj->SetSubMch_id();
$inputObj->SetSubOpen_id();

然而,并没有。甚至我在整个SDK里搜索sub这三个字母都无结果。所以这里需要我们自己去添加对应的方法。emmmmmm这算是个小坑吧,自己改改就完。
然后说回这仨值,这仨值是必填!必填!必填!否则报错,而文档里sub_app_id、sub_openid的必填属性为否。
嗯,好,以上的都没啥问题了,后边签名又过不去了,看文档(服务商版):
嗯,没毛病,不过怎么看着就面熟呢?再看看普通商户版:
这特么不一个样么?不都是按照ASCII码从小到大排序拼接然后拼上商户密钥key最后md5运算转换大写么?为什么我就是不通过?
那么,坑来了,这个商户密钥key,服务商版的需要用服务商的商户密钥key,而不是子商户的。
好的,换上了服务商的key,通过了,也获取到了前端调起支付需要的xml数据了,然后兴冲冲拿着这些数据去进行签名处理好发给前端调起支付了,然后前端也确实得到了timeStamp、nonceStr、package、signType、paySign这五个参数(以小程序为例),然后前端也兴冲冲的拿起手机扫码付款了,这时候突然手机上一个弹窗:支付签名验证失败。然后一下子就从天上掉下来了,为什么?不是签名通过了么?参数也都全啊?怎么就又签名失败了?
于是跑回去各种检查各种尝试,然后对比检查到了签名这一块:appid、timeStamp、nonceStr、package、signType都设置了啊,package也按要求prepay_id=的方式填写了啊,到底怎么回事?恭喜又踩坑了,这里的appid不能使用服务商的,而是使用子商户的,也就是sub_app_id。
改完参数后,emmmmmmm成了。
以上是付款遇到的坑,emmmmm还有退款,年代久远都忘了,等新项目对接到了再说吧,又得头疼一阵子。

瞎搞服务器忘了备份数据库。。。

随便搞了个模板扔了上去结果忘了改,被网信办通知了。。。

安装twothink

进入网站项目路径,使用git克隆

git clone “https://gitee.com/ypwl/TwoThink.git”

克隆完毕会在项目目录生成TwoThink文件夹,如果你想把twothink放在项目根目录,只需要进入TwoThink将其中文件移动出来即可。

配置apache设置网站指向twothink的public文件夹,这里就不多说了。

TwoThink开发使用可以参考TwoThink的文档

浏览器打开http://你的域名/install.php即可进行安装,安装过程不再详细描述类似OneThink;

安装GatewayWorker

使用composer方式安装GatewayWorker,由于twoThink基于thinkphp5.0,think-worker只能安装1.0版本的,

进图twothink项目目录,安装workerman

composer require topthink/think-worker:1.*

安装gateway

composer require workerman/gateway-worker-for-win

新建或编辑build.php

return [
    // 生成应用公共文件
    '__file__' => ['common.php', 'config.php', 'database.php'],
    // 定义demo模块的自动生成 (按照实际定义的文件名生成)
    'chat'     => [
        '__file__'   => ['common.php'],
        '__dir__'    => ['behavior', 'controller', 'model', 'view'],
        'controller' => ['Index', 'Events', 'Gate'],
        'model'      => ['User', 'UserType'],
        'view'       => ['index/index'],
    ],
    // 其他更多的模块定义
];

拷贝至application目录下运行

php think build

显示Successed即为创建成功。

进入application/chat/controller目录,编辑Events控制器

<?php
    namespace app\chat\controller;

    /**
     * 用于检测业务代码死循环或者长时间阻塞等问题
     * 如果发现业务卡死,可以将下面declare打开(去掉//注释),并执行php start.php reload
     * 然后观察一段时间workerman.log看是否有process_timeout异常
     */
    //declare(ticks=1);

    /**
     * 聊天主逻辑
     * 主要是处理 onMessage onClose 
     */
    use \GatewayWorker\Lib\Gateway;

    class Events
    {
        /**
        * 有消息时
        * @param int $client_id
        * @param mixed $message
        */
       public static function onMessage($client_id, $message)
       {
            // debug
            echo "client:{$_SERVER['REMOTE_ADDR']}:{$_SERVER['REMOTE_PORT']} gateway:{$_SERVER['GATEWAY_ADDR']}:{$_SERVER['GATEWAY_PORT']}  client_id:$client_id session:".json_encode($_SESSION)." onMessage:".$message."\n";
    
            // 客户端传递的是json数据
            $message_data = json_decode($message, true);
            if(!$message_data)
            {
                return ;
            }
    
            // 根据类型执行不同的业务
            switch($message_data['type'])
            {
                // 客户端回应服务端的心跳
                case 'pong':
                    return;
                // 客户端登录 message格式: {type:login, name:xx, room_id:1} ,添加到客户端,广播给所有客户端xx进入聊天室
                case 'login':
                    // 判断是否有房间号
                    if(!isset($message_data['room_id']))
                    {
                        throw new \Exception("\$message_data['room_id'] not set. client_ip:{$_SERVER['REMOTE_ADDR']} \$message:$message");
                    }
            
                    // 把房间号昵称放到session中
                    $room_id = $message_data['room_id'];
                    $client_name = htmlspecialchars($message_data['client_name']);
                    $_SESSION['room_id'] = $room_id;
                    $_SESSION['client_name'] = $client_name;
          
                    // 获取房间内所有用户列表 
                    $clients_list = Gateway::getClientSessionsByGroup($room_id);
                    foreach($clients_list as $tmp_client_id=>$item)
                    {
                        $clients_list[$tmp_client_id] = $item['client_name'];
                    }
                    $clients_list[$client_id] = $client_name;
                    
                    // 转播给当前房间的所有客户端,xx进入聊天室 message {type:login, client_id:xx, name:xx} 
                    $new_message = array('type'=>$message_data['type'], 'client_id'=>$client_id, 'client_name'=>htmlspecialchars($client_name), 'time'=>date('Y-m-d H:i:s'));
                    Gateway::sendToGroup($room_id, json_encode($new_message));
                    Gateway::joinGroup($client_id, $room_id);
           
                    // 给当前用户发送用户列表 
                    $new_message['client_list'] = $clients_list;
                    Gateway::sendToCurrentClient(json_encode($new_message));
                    return;
            
                // 客户端发言 message: {type:say, to_client_id:xx, content:xx}
                case 'say':
                    // 非法请求
                    if(!isset($_SESSION['room_id']))
                    {
                        throw new \Exception("\$_SESSION['room_id'] not set. client_ip:{$_SERVER['REMOTE_ADDR']}");
                    }
                    $room_id = $_SESSION['room_id'];
                    $client_name = $_SESSION['client_name'];
            
                    // 私聊
                    if($message_data['to_client_id'] != 'all')
                    {
                        $new_message = array(
                            'type'=>'say',
                            'from_client_id'=>$client_id, 
                            'from_client_name' =>$client_name,
                            'to_client_id'=>$message_data['to_client_id'],
                            'content'=>"<b>对你说: </b>".nl2br(htmlspecialchars($message_data['content'])),
                            'time'=>date('Y-m-d H:i:s'),
                        );
                        Gateway::sendToClient($message_data['to_client_id'], json_encode($new_message));
                        $new_message['content'] = "<b>你对".htmlspecialchars($message_data['to_client_name'])."说: </b>".nl2br(htmlspecialchars($message_data['content']));
                        return Gateway::sendToCurrentClient(json_encode($new_message));
                    }
            
                    $new_message = array(
                        'type'=>'say', 
                        'from_client_id'=>$client_id,
                        'from_client_name' =>$client_name,
                        'to_client_id'=>'all',
                        'content'=>nl2br(htmlspecialchars($message_data['content'])),
                        'time'=>date('Y-m-d H:i:s'),
                    );
                    return Gateway::sendToGroup($room_id ,json_encode($new_message));
            }
       }

       /**
        * 当客户端断开连接时
        * @param integer $client_id 客户端id
        */
       public static function onClose($client_id)
       {
           // debug
           echo "client:{$_SERVER['REMOTE_ADDR']}:{$_SERVER['REMOTE_PORT']} gateway:{$_SERVER['GATEWAY_ADDR']}:{$_SERVER['GATEWAY_PORT']}  client_id:$client_id onClose:''\n";
   
           // 从房间的客户端列表中删除
           if(isset($_SESSION['room_id']))
           {
               $room_id = $_SESSION['room_id'];
               $new_message = array('type'=>'logout', 'from_client_id'=>$client_id, 'from_client_name'=>$_SESSION['client_name'], 'time'=>date('Y-m-d H:i:s'));
               Gateway::sendToGroup($room_id, json_encode($new_message));
           }
       }

    }

编辑Gate控制器

<?php
    namespace app\chat\controller;

    use Workerman\Worker;
    use GatewayWorker\Gateway;
    use GatewayWorker\Register;
    use GatewayWorker\BusinessWorker;

    class Gate
    {
    /**
         * 构造函数
         * @access public
         */
        public function __construct(){
    
            //初始化各个GatewayWorker
            //初始化register register 服务必须是text协议
            $register = new Register('text://0.0.0.0:1236');

            //初始化 bussinessWorker 进程
            $worker = new BusinessWorker();
            // worker名称
            $worker->name = 'ChatBusinessWorker';
            // bussinessWorker进程数量
            $worker->count = 4;
            // 服务注册地址
            $worker->registerAddress = '127.0.0.1:1236';
            //设置处理业务的类,此处制定Events的命名空间
            $worker->eventHandler = 'app\chat\controller\Events';
            // 初始化 gateway 进程
            $gateway = new Gateway("websocket://0.0.0.0:9501");
            // 设置名称,方便status时查看
            $gateway->name = 'ChatGateway';
            $gateway->count = 4;
            // 分布式部署时请设置成内网ip(非127.0.0.1)
            $gateway->lanIp = '127.0.0.1';
            // 内部通讯起始端口,假如$gateway->count=4,起始端口为4000
            // 则一般会使用4000 4001 4002 4003 4个端口作为内部通讯端口
            $gateway->startPort = 2300;
            // 心跳间隔
            $gateway->pingInterval = 10;
            // 心跳数据
            $gateway->pingData = '{"type":"ping"}';
            // 服务注册地址
            $gateway->registerAddress = '127.0.0.1:1236';

            //运行所有Worker;
            Worker::runAll();
        }
    }

编辑入口文件 start.php,将入口文件放在public文件夹内

<?php
    /**
     * workerman + GatewayWorker
     * 此文件只能在Linux运行
     * run with command
     * php start.php start
     */
    ini_set('display_errors', 'on');
    if(strpos(strtolower(PHP_OS), 'win') === 0)
    {
        exit("start.php not support windows.\n");
    }
    //检查扩展
    if(!extension_loaded('pcntl'))
    {
        exit("Please install pcntl extension. See http://doc3.workerman.net/appendices/install-extension.html\n");
    }
    if(!extension_loaded('posix'))
    {
        exit("Please install posix extension. See http://doc3.workerman.net/appendices/install-extension.html\n");
    }
    define('APP_PATH', __DIR__ . '/../application/');
    define('BIND_MODULE','chat/Gate');
    // 加载框架引导文件
    require __DIR__ . '/../thinkphp/start.php';

进入public目录使用以下命令启动

php start.php start

此处参考http://www.thinkphp.cn/code/3937.html

Apache2反向代理ws

Apache反向代理之后可以隐藏端口号直接ws://域名
需要安装proxy和proxy_wstunnel模块
输入命令

sudo a2enmod proxy
sudo a2enmod proxy_wstunne

然后重启apache服务

service apache2 restart

编辑配置

我的apache是使用apt直接安装的,其配置文件在/etc/apache2/sites-enabled路径下,

输入nano 000-default.conf进行编辑,找到下方位置,做出如下修改。

<VirtualHost *:80>
    ServerName 你的域名
    DocumentRoot 项目路径/twothink/public
    ServerAdmin admin@admin.com
    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    ProxyPass / ws://localhost:9501/
    ProxyPassReverse / ws://localhost:9501/
</VirtualHost>

注意:该修改一旦生效则对应域名只能以ws协议请求,不能再使用http协议。

Crtl+O进行保存,crtl+x退出编辑器,重启服务器即可
参考文章https://blog.csdn.net/sunhuwh/article/details/48215831

配置wss

这里以服务器开启ssl为前提。

配置ws+ssl需要ssl证书,可以自己生成也可以购买证书,将证书文件放在/etc/apache2/certs/路径下(根据个人喜好,只要配置的时候路径正确即可)

配置/etc/apache2/sites-enabled/default-ssl.conf

在对应域名配置中进行如下设置:

# Proxy Config
SSLProxyEngine on

ProxyRequests Off
ProxyPass /wss ws://127.0.0.1:9501
ProxyPassReverse /wss ws://127.0.0.1:9501

# 添加 SSL 协议支持协议,去掉不安全的协议
SSLProtocol all -SSLv2 -SSLv3
# 修改加密套件如下
SSLCipherSuite HIGH:!RC4:!MD5:!aNULL:!eNULL:!NULL:!DH:!EDH:!EXP:+MEDIUM
SSLHonorCipherOrder on
# 证书公钥配置
SSLCertificateFile 你的证书路径/your.pem
# 证书私钥配置
SSLCertificateKeyFile 你的证书路径/your.key
# 证书链配置,
SSLCertificateChainFile 你的证书路径/chain.pem

保存重启apache

在chrome中进行测试

// 证书是会检查域名的,请使用域名连接
ws = new WebSocket("wss://域名");
ws.onopen = function() {
    alert("连接成功");
    ws.send('tom');
    alert("给服务端发送一个字符串:tom");
};
ws.onmessage = function(e) {
    alert("收到服务端的消息:" + e.data);
};

【更新】2018-09-30

重写了组件,新增了一个样式,新增了一堆参数,原样式进行了部分调整,具体使用参照这里

下面那堆可以不用管了
demo目录
以下是示例图(无视那个熊猫)
加载中
加载中
成功
成功
警告
警告
自定义图片
自定义图片
我自定义组件放在了/components/toast文件夹下

toast.wxml部分

<view class="toast" wx:if="{{param.title || param.icon}}">
    <view class="toast-block">
        <image class="{{param.icon}}" src="{{param.image?param.image:(param.icon!='none'?'./icons/'+(param.icon||'success')+'.png':'')}}" bindload="onImageLoad" style="{{param.image?'width:'+width+';height:'+height+';':''}}"></image>
        <view class="title {{param.icon}}">{{param.title}}</view>
    </view>
</view>

toast.wxss部分

.toast {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    position: fixed;
    top: 0;
    left: 0;
    z-index: 9999;
}

.toast-block {
    max-width: 540rpx;
    min-width: 200rpx;
    padding: 30rpx;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.8);
    border-radius: 10rpx;
}

.toast-block>image {
    width: 60rpx;
    height: 60rpx;
    max-width: 540rpx;
    max-height: 540rpx;
    display: block;
}

.toast-block>image.loading {
    animation:circle 1s infinite linear;
}
@keyframes circle{
    0%{ transform:rotate(0deg); }
    100%{ transform:rotate(360deg); }
}
.toast-block>image.none{
    display: none;
}
.toast-block .title {
    margin-top: 20rpx;
    line-height: 29rpx;
    font-size: 30rpx;
    color: #fff;
    text-align: center;
    width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 1;
    -webkit-box-orient: vertical;
}
.toast-block .none{
    margin-top: 0;
}

toast.js部分

Component({
    properties: {
        param:{
            type: Object,
            value: { icon: "", image: "", title: "", duration:1000 },
            observer: function (newVal, oldVal) {
                if (!this.isPlainObject(newVal)){
                    let pages = getCurrentPages();
                    let page = pages[pages.length - 1];
                    var duration = typeof newVal == 'object' ? newVal.duration : 1000;
                    setTimeout(() => {
                        page.setData({
                            toast: {}
                        })
                    }, duration);
                }
            }
        }
    },
    data: {},
    methods: {
        isPlainObject: function(obj){
            for (var name in obj) {
                return false;
            }
            return true;
        },
        onImageLoad:function(e){
            this.setData({
                width: e.detail.width * 2 > 540 ? 540:e.detail.width * 2 + 'rpx',
                height: e.detail.height * 2 > 540 ? 540:e.detail.height * 2 + 'rpx'
            })
        }
    }
})

以上是自定义组件的内容,里面包含了三张图片,懒得发,自己画一个就行,三个文件名字分别是success,warning,loading,格式为png。

 

 

icon图片大小固定为60rpx*60rpx。

image图片会自动判断大小,限制最大尺寸为540rpx*540rpx;

说说用法,

在app.js里插入

/**
 * toast模态弹窗
 * param.icon       string      弹框类型 success成功,warning警告,loading加载,none无图标,默认success
 * param.image      string      自定义图标
 * param.title      string      弹框内容
 * param.duration   int         弹窗显示时间,单位:毫秒,默认1秒
 */
showToast:function(param){
    let pages = getCurrentPages();
    let page = pages[pages.length - 1];
    page.setData({
        toast:param
    })
},

在需要使用的页面的json里加入

"usingComponents": {
    "toast": "/components/toast/toast"
}

然后在对应页面wxml最底部或最顶部加入

<toast param="{{toast}}"></toast>

然后对应页面的js中需要弹窗提示信息的地方加上

 app.showToast({
    title:'测试',
    duration:2000
 });

完。