个人案例
- 不知道卖啥的店铺
不知道卖啥,或许你可以给个建议?
不知道卖啥的店铺扫码体验
- 通过WXS实现回弹的平滑滚动容器
前言 最近在愉快的开发微信小程序的时候碰到了一个体验需求,需要在 Android 侧的滚动也需要带回弹效果,类似于在 Web 端可以使用的 better-scroll,查阅微信小程序内置组件 [代码]scroll-view[代码] 无法满足这种场景,没办法,需求得做呀,只能自己动手撸了! 在微信小程序中,我们可以通过 WXS响应事件 来替代逻辑层处理从而有效的提高交互流畅度,其中使用到的 WXS语法 也是非常类似我们非常熟悉 JavaScript,不过很多的 JavaScript 高级语法在 WXS 模块中不能使用,具体可以点击链接进入微信小程序提供的文档。 思路 以横向滚动为例,内容的宽度大于容器的宽度时可以发生滚动,如图 [图片] 接着通过监听三个触摸事件[代码]touchstart[代码]、[代码]touchmove[代码]、[代码]touchend[代码]来实时的改变 content 的 CSS translate,从而从视觉上达到滚动的目的。 WXS 示例 我们先从一个简单的 WXS 使用示例来了解回顾一下使用方式,WXS 的模块系统类似 CommomJS 规范,使用每个模块内置的 [代码]module[代码] 对象中的 [代码]exports[代码] 属性进行变量、函数导出: [代码]// helper.wxs module.exports = { // 注意 WXS 模块中不支持函数简写 touchstart: function touchstart() { console.log('touchstart called') } } [代码] [代码]<!-- index.wmxl --> <!-- module 为模块名,可按规范任意取名 --> <wxs src="./helper.wxs" module="helper" /> <!-- 与普通的逻辑层事件不同,这里需要加上 {{}} --> <view bind:touchstart="{{ helper.touchstart }}">view</view> [代码] 这样就给 [代码]view[代码] 绑定了一个 [代码]touchstart[代码] 事件,在事件触发后,会在控制台打印出字符串 "touchstart called" 好了,现在正式进入滚动容器的逻辑实现 开工 新建 [代码]scroll.wxml[代码] 文件,准备符合上图中结构的 WXML 内容来构造出一个正确的可以滚动条件 [代码]<!-- scroll.wxml --> <!-- 即图中的 container --> <view class="container" style="width: 100vw;"> <!-- 即图中的 content --> <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 新建 [代码]scroll.wxs[代码] 文件,里边用于存放我们实现滚动的所有逻辑 接下来进行初始化操作,首先需要获取到 container 和 content 组件实例,在上一节 “WXS 示例” 中我们知道可以通过在组件中触发一个事件来调用 WXS 模块中的方法,但有没有什么方式可以不用等到用户来触发事件就可以执行吗? 通过阅读 WXS 响应事件 文档,可以了解到,另外一种调用 WXS 模块方法就是可以通过 [代码]change:[prop][代码] 监听某一个组件的 Prop 的改变来执行 WXS 模块中指定的方法,且这个方法会立即执行一次,如下面一个示例 [代码]// helper.wxs module.exports = { setup: function setup() { console.log('setup') } } [代码] [代码]<!-- index.wxml --> <wxs src="./helper.wxs" module="helper"></wxs> <!-- 例如我们指定一个 prop 为 prop1,值为 {{ prop1Data }} --> <!-- 通过 change:prop1 语法对这个 prop 的变化进行监听 --> <view prop1="{{ prop1Data }}" change:prop1="{{ helper.setup }}"></view> [代码] [代码]// index.js Page({ data: { prop1Data: {} } }) [代码] 上面示例中,在页面初始化或 [代码]prop1Data[代码] 发生改变时(准确来说是在逻辑层对 [代码]prop1Data[代码] 调用了 [代码]setData[代码] 方法后,即使 [代码]prop1Data[代码] 的内容不变化),都会调用 [代码]hepler.wxs[代码] 模块中的 setup 方法。 现在我们可以通过 [代码]change:prop[代码] 会立即执行一次的特点,来对我们的滚动逻辑进行一次初始化操作 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } [代码] [代码]<!-- scroll.wxml --> <wxs src="./scroll.wxs" module="scroll" /> <!-- 因本案例只利用 change:[prop] 首次执行的机制,传递的给 _ 的参数是个对象字面量 --> <view class="container" style="width: 100vw;" _="{{ { k: '' } }}" change:_="{{ scroll.setup }}" bind:touchstart="{{ scroll.touchstart }}" bind:touchmove="{{ scroll.touchmove }}" bind:touchend="{{ scroll.touchend }}" > <view class="content" style="display: inline-block; white-space: nowrap;"> <view wx:for="{{ 10 }}" wx:key="index" style="width: 200rpx; height: 300rpx; border: 1px solid; display: inline-block;">{{ item }}</view> </view> </view> [代码] 完成基本的跟随手指移动 [代码]// scroll.wxs var exports = module.exports // 页面实例 var ownerInstance // container BoundingClientRect var containerRect // content 实例,通过此实例设置 CSS 属性 var slidingContainerInstance // content BoundingClientRect var slidingContainerRect // X方向的最小、最大滚动距离。如 -200 至 0(手势往右时,元素左移,translateX 为负值) var minTranslateX var maxTranslateX = 0 /** * @param newValue 最新的属性值 * @param oldValue 旧的属性值 * @param ownerInstance 页面所在的实例 * @param instance 触发事件的组件实例 */ exports.setup = function setup(newValue, oldValue, _ownerInstance, instance) { ownerInstance = _ownerInstance containerRect = instance.getBoundingClientRect() slidingContainerInstance = ownerInstance.selectComponent('.content') slidingContainerRect = slidingContainerInstance.getBoundingClientRect() minTranslateX = (slidingContainerRect.width - containerRect.width) * -1 } // 实时记录 content 位置 var pos = { x: 0 } // 记录每次触摸事件开始时,content 的位置,后续的移动都是基于此值增加或减少 var startPos = { x: 0 } // 记录触摸开始时,手指的位置,后续需要通过比较此值来计算出移动量 var startTouch = { clientX: 0 } function setTranslate(pos0) { slidingContainerInstance.setStyle({ transform: 'translateX(' + pos0.x + 'px)' }) pos.x = pos0.x } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x } exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX setTranslate({ x: x }) } exports.touchend = function touchend() {} [代码] 效果图: [图片] 处理松手后移动超出的情况,需要对其归位: 添加 clamp 工具方法 [代码]// 给出最小、最大、当前值,返回一个在最下-最大范围之间的结果 // 如: -100, 0, -101 => -100 function clamp(min, max, val) { return Math.max(min, Math.min(max, val)) } [代码] 在 touchend 事件中,添加位置校验的逻辑 [代码]// scroll.wxs exports.touchend = function touchend() { setTranslate({ x: clamp(minTranslateX, maxTranslateX, pos.x) }) } [代码] 看看效果: [图片] 回去是能回去了,有点生硬~ 加上松手回弹动画 其中动画可以使用两种实现方式 CSS Transition:在松手后,给 content 元素设置一个 [代码]transition[代码],然后调整 [代码]translateX[代码] 值归位 JS 帧动画:在松手后,利用动画函数不断调整 [代码]translateX[代码] 来进行归位 两种方式通过给相同的动画函数可以达到一样的体验,但 CSS Transition 在我的理解中不太好处理中止的情况,如在动画过程中,又有了新的触摸事件,这里就会产生抖动或未预期到的结果,但 JS 动画可以很简单的应对 因此后续的动画部分打算采用 JS 动画实现,先准备一些动画函数 [代码]// scroll.wxs // 下面内容通过 better-scroll 借鉴 ~ // 可以理解为入参是一个 [0, 1] 的值,返回也是一个 [0, 1] 的值,用来表示进度 var timings = { v1: function (t) { return 1 + --t * t * t * t * t }, v2: function(t) { return t * (2 - t) }, v3: function(t) { return 1 - --t * t * t * t } } [代码] 定义 [代码]moveFromTo[代码] 方法来实现从一个点通过指定的动画函数运动到另一点 [代码]// scroll.wxs /** * @param fromX 起始点xx * @param toX 目标点 x * @param duration 持续时长 * @param timing 动画函数 */ function moveFromTo(fromX, toX, duration, timing) { if (duration === 0) { setTranslate({ x: fromX }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } } ownerInstance.requestAnimationFrame(rAFHandler) } } [代码] 调整 touchend 事件处理逻辑,添加归位的动画效果 [代码]// scroll.wxs exports.touchend = function touchend() { moveFromTo( pos.x, clamp(minTranslateX, maxTranslateX, pos.x), 800, timings.v1 ) } [代码] 看看效果: [图片] 看起来达到了目的,再优化一下,在滑动超出边界后,需要给一些阻力,不能滑的“太简单了” 给超边界的滚动加阻力 [代码]// scroll.wxs exports.touchmove = function touchmove(event) { var deltaX = event.changedTouches[0].clientX - startTouch.clientX var x = startPos.x + deltaX // 阻尼因子 var damping = 0.3 if (x > maxTranslateX) { // 手指右滑导致元素左侧超出,超出部分添加阻尼行为 x = maxTranslateX + damping * (x - maxTranslateX) } else if (x < minTranslateX) { // 手指左滑导致元素右侧超出,超出部分添加阻尼行为 x = minTranslateX + damping * (x - minTranslateX) } setTranslate({ x: x }) } [代码] 瞅瞅: [图片] 效果达到了,手指都划出屏幕了,才移动了这么一点距离 到现在已经完成了一个带回弹效果的滚动容器,但还没有做到“平滑”,即在滑动一段距离松手后,需要给 content 一些“惯性”来继续移动一些距离,体验起来就不会那么生硬 加滑动惯性 在这之前,还有一些准备工作需要做 [代码]// scroll.wxs // 记录触摸开始的时间戳 + var startTimeStamp = 0 // 增加动画完成回调 + function moveFromTo(fromX, toX, duration, timing, onComplete) { if (duration === 0) { setTranslate({ x: fromX }) + ownerInstance.requestAnimationFrame(function() { + onComplete && onComplete() + }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) + } else { + onComplete && onComplete() + } } ownerInstance.requestAnimationFrame(rAFHandler) } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x + startTimeStamp = event.timeStamp } [代码] 因为是在松手后加动量,所以继续处理 touchend [代码]// scroll.wxs exports.touchend = function touchend(event) { // 记录这一轮触摸动作持续的时间 var eventDuration = event.timeStamp - startTimeStamp var finalPos = { x: pos.x } var duration = 0 var timing = timings.v1 var deceleration = 0.0015 // 计算动量,以下计算方式“借鉴”于 better-scroll,有知道使用什么公式的朋友告知以下~ var calculateMomentum = function calculateMomentum(start, end) { var distance = Math.abs(start - end) var speed = distance / eventDuration var dir = end - start > 0 ? 1 : -1 var duration = Math.min(1800, (speed * 2) / deceleration) var delta = Math.pow(speed, 2) / deceleration * dir return { duration: duration, delta: delta } } // 此次滑动目的地还在边界中,可以进行动量动画 if (finalPos.x === clamp(minTranslateX, maxTranslateX, finalPos.x)) { var result = calculateMomentum(startPos.x, pos.x) duration = result.duration finalPos.x += result.delta // 加上动量后,超出了边界,加速运动到目的地,然后触发回弹效果 if (finalPos.x > maxTranslateX || finalPos.x < minTranslateX) { duration = 400 timing = timings.v2 var beyondDis = containerRect.width / 6 if (finalPos.x > maxTranslateX) { finalPos.x = maxTranslateX + beyondDis } else { finalPos.x = minTranslateX + beyondDis * -1 } } } moveFromTo(pos.x, finalPos.x, duration, timing, function () { // 若动量动画导致超出了边界,需要进行位置修正,也就是回弹动画 var correctedPos = { x: clamp(minTranslateX, maxTranslateX, pos.x) } if (correctedPos.x !== pos.x) { moveFromTo( pos.x, correctedPos.x, 800, timings.v1 ) } }) } [代码] 继续看看效果: [图片] 有了有了 只是现在的滚动容器还很“脆弱”,在进行动量动画、回弹动画时,如果手指继续开始一轮新的触摸,就会出现问题,也就是最开始我们在选择 CSS 过渡和 JS 动画考虑到的问题 解决连续触摸滑动问题 在 [代码]moveFromTo[代码] 方法中,添加强制中止的逻辑 [代码]// scroll.wxs + var effect = null function moveFromTo(fromX, toX, duration, timing, onComplete) { + var aborted = false if (duration === 0) { setTranslate({ x: fromX }) ownerInstance.requestAnimationFrame(function () { onComplete && onComplete() }) } else { var startTime = Date.now() var disX = toX - fromX var rAFHandler = function rAFHandler() { + if (aborted) return var progressX = timing(clamp(0, 1, (Date.now() - startTime) / duration)) setTranslate({ x: disX * progressX + fromX }) if (progressX < 1) { ownerInstance.requestAnimationFrame(rAFHandler) } else { onComplete && onComplete() } } ownerInstance.requestAnimationFrame(rAFHandler) } + if (effect) effect() + effect = function abort() { + if (!aborted) aborted = true + } } exports.touchstart = function touchstart(event) { startTouch.clientX = event.changedTouches[0].clientX startPos.x = pos.x startTimeStamp = event.timeStamp + if (effect) { + effect() + effect = null + } } [代码] 体验一下: [图片] 这样一个带回弹的平滑滚动容器就处理的可以使用啦,有问题的地方欢迎大家指出讨论 结尾 完整源码托管在 Github 中:weapp-scroll 其中功能、逻辑更为完善,并同时支持横向、竖向方向的滚动,适合在 Android、PC 场景的使用(毕竟 IOS 侧可以直接使用微信内置组件 [代码]scroll-view[代码]~)。若有帮到希望可以给个星星~ 完~
2023-07-07 - 小程序公众号干货运营之注销篇
各位亲,面对帐号注销是不是束手无策呢?帐号如何注销,怎么注销,注销需要提供什么信息内容呢?请仔细往下看看 小程序 关于小程序注销的条件,若未冻结的个人帐号和组织类帐号就不 一 一 细讲,详情请参腾讯客服文档:https://kf.qq.com/product/wx_xcx.html#hid=2826 1:小程序注销之政府无对公账户: 详细流程请参考:https://kf.qq.com/faq/190104YnQbYN190104RzaYba.html 政府的有一致主体是提供一致的主体证件和公章,如果有变更请提供4项材料:因机构改革、单位合并、撤一建一等情况,导致机构主体名称有变更,提供以下材料申请注销: 1、更名相关的红头文件(有鲜章); 2、主体名称变更情况说明书(加盖新主体公章); 3、变更后新主体的主体证件;(原件拍照或加盖公章的复印件) 4、注销申请函(加盖新主体公章); 2:小程序注销之个体工商户 若个体工商户存在对公账户,请使用对公账户小额打款注销 若个体工商户类型无对公账户注销小程序工单指引流程如下 工单所需材料 1、小程序绑定邮箱/原始ID: 2、主体证件材料(营业执照/组织机构代码证等): 3、小程序绑定的法人身份证原件正反面的清晰扫描件或照片: 4、小程序的注销书面申请,申请书必须加盖公章。(若个体户没有公章可支持法人手写签名) 附注:注销申请书模板(https://kf.qq.com/faq/200306R7N3mI200306I3aEBz.html) 材料提交链接:https://kf.qq.com/touch/bill/200306selfqaafe6c551.html(手机端打开) 3:小程序注销之帐号主体已注销 主体已注销小程序工单指引流程如下, 1、小程序绑定邮箱/原始ID: 2、主体注销证明: 3、小程序绑定的法人身份证原件正反面的清晰扫描件或照片: 4、小程序的注销书面申请,企业账号的申请必须有加盖公章的函件(公章被收的请法人手写签字)附注:注销申请书模板(https://kf.qq.com/faq/200306R7N3mI200306I3aEBz.html) 材料提交链接:https://kf.qq.com/touch/bill/200306selfqaafe6c551.html(手机端打开) 4:小程序注销之门店小程序 门店小程序依附于公众号,不支持单独注销,公众号注销门店小程序才支持注销 5:公众号正常运营,门店小程序如何释放昵称 如果需要释放该小店小程序昵称,发送邮件到“miniprogram@tencent.com”,标题格式【关于XXX名称释放请求】,需提供以下材料: 1、小程序帐号(原始ID); 2、绑定的管理员微信号; 3、小程序主体营业执照等主体证件; 4、小程序所有者的书面申请,申请书需加盖小程序主体公章;(个体户无公章:申请书需要加上法人签名); 邮件内容:需包含背景、释放请求原因。 6:复用公众号资质快速注册的小程序如何注销 复用资质申请的小程序是独立存在的,请按照正常流程注销即可 7:注册小程序选择微信认证,若未完成微信认证如何注销呢? 小程序30天未认证或认证失败且7天内未发起认证不会释放邮箱,但该邮箱支持重新注册小程序,会释放主体信息、管理员信息、昵称。 公众号 关于公众号若未冻结的个人帐号和组织类帐号就不一一细讲,详情请参考腾讯客服文档:https://kf.qq.com/product/weixinmp.html#hid=2267 1:公众号注销之政府无对公账户: 详细流程请参考:https://kf.qq.com/faq/190531qyuuiY190531BjyyEv.html 政府的有一致主体是提供一致的主体证件和公章,如果有变更请提供4项材料:因机构改革、单位合并、撤一建一等情况,导致机构主体名称有变更,提供以下材料申请注销: 1、更名相关的红头文件(有鲜章); 2、主体名称变更情况说明书(加盖新主体公章); 3、变更后新主体的主体证件;(原件拍照或加盖公章的复印件) 4、注销申请函(加盖新主体公章); 2:公众号注销之个体工商户 若个体工商户存在对公账户,请使用对公账户小额打款注销 若个体工商户类型无对公账户,请使用法人扫脸注销公众号 详情请参考:https://kf.qq.com/faq/220309bUvmIB220309BbAjMz.html 3:公众号注销之帐号主体已注销 主体已注销公众号工单指引流程如下, 1、公众号绑定邮箱/原始id/微信号: 2、主体注销证明: 3、公众号绑定的法人身份证原件正反面的清晰扫描件或照片: 4、公众号的注销书面申请,企业账号的申请必须有加盖公章的函件(公章被收的请法人手写签字) 附注:注销申请书模板(http://kf.qq.com/faq/171018R3IVBF171018INjUvA.html ) 材料提交链接:https://kf.qq.com/touch/bill/180227selfqa9ab6ac55.html(手机端打开) 4:未注册成功的帐号如何注销 若帐号当时没有走完注册流程且长期没有登录该帐号,到期会被系统注销。没有走完注册流程的帐号不占用个人信息,也不支持找回,建议重新注册 5:注册公众号选择微信认证,若未完成微信认证如何注销呢? 若公众号注册时选择微信认证,自注册日起30天内未进行认证(第30天仍在认证中不算),点击“重新提交材料”,帐号角色变为注册失败,不会释放帐号邮箱,但该邮箱支持重新注册公众号,会释放主体信息、管理员信息、昵称, 6:小程序公众号注销确认期 注销确认期的7天内每天会发送一次确认注销的通知,若管理员一直未点击确认注销则默认取消注销,注销失败。因此管理员请关注公众平台安全助手!!!
2022-04-08 - 小程序请求数据双向混合加密和防篡改+防重放攻击的实现
前言 大家好,借着中秋放假明日又要上班的这个晚上,平常又没空,趁这个时间点就决定来一篇。 [图片] 我们都知道微信小程序的服务端API上,官方可谓是做足了心思,对用户的数据进行加密,虽然对我们开发者来说似乎是一种麻烦,但是从长远角度来看,是十分有必要的,用户隐私高于一切。 那么在小程序的开发过程中与后端对接接口时是否有想过这样的问题呢? HTTPS真的百分百安全吗? 数据被成功抓包后安全吗? 数据被篡改后还有效吗? 请求被重放后安全吗? 世界上没有绝对安全的系统,但我们可以让它被破解的成本变高。 本篇文章专业性并不高,如果存在错误请大家为我指出来,以免误导别人,谢谢! 以下我们将小程序端称为C端,服务端称为S端,服务端代码是Node.js,仅供参考,但原理都一样,后端可以是其它语言。 思考 围绕着以上的问题,探究一下问题的答案? 本部分只对问题做思考,具体实现请参考下面的实现部分。 HTTPS真的百分百安全吗?只能说是相对安全,当然,在微信小程序的沙箱环境里,HTTPS通信会更加安全,否则官方可能会要求我们对请求加密了对吧。但百密一疏,C端是否存在漏洞,假设C端安全了难道S端就安全嘛?细思恐极,退一万步讲,百分百安全是不存在的,由于篇幅问题相关漏洞大家可以搜索探究。 假设数据被中间人成功抓包,如果数据是明文传输,那么将导致数据泄露,因此对数据进行加密是必要的,但应该如何加密呢?如何做到密钥安全?C端和S端如何进行数据的加解密?RSA和AES加密应该使用哪种加密明文?如何充分发挥RSA和AES两个加密算法的特长? 假设数据被篡改,如果因为请求数据被篡改而导致严重后果,那么很大程度上其实是代码设计有问题,正常设计中安全应被摆在重要的位置,至少不应该出现购买某样商品时是通过C端发送商品价格给S端调起支付那样(只需篡改商品价格即可支付极少的钱购买商品),如果代码设计并不存在严重问题,那么数据被篡改也是不可忽视的,我们需要进行数据签名,让不同的数据拥有唯一的MD5哈希值,如果数据被篡改,通过哈希值即可判断数据是否被篡改,当然也可能会有人问,既然数据被篡改,那攻击者会不会重新生成一个哈希值代替?是的。但是我们有接下来会说到的key,key会作为签名的数据变量之一,由于攻击者并不知道key值因此无法重新签名,key值建议不是固定值而是周期性更换的随机值,例如随着用户的登录态而产生并随之抹除。 假设请求被重放,大部分时候由于数据被加密和防篡改处理过,攻击者并无法直接获得数据或篡改,但如果通过劫持到的登录认证请求的原始数据并重新发起该请求,则攻击者将可能获得重要的认证数据,使得系统将攻击者作为正常用户处理,后续的请求攻击者仍能伪装成正常的用户进行后续攻击。 期望效果 在开始实现之前先看一下完成后的效果,以下是截取了Network中其中一个GET请求发送的数据: [图片] 实际上发送的数据是如下图所示: [图片] 然后是该请求返回的数据: [图片] 实际上返回的数据是如下图所示: [图片] 整个流程: [图片] 实现 由于整个流程在C端的实现上顺序反过来的,因此下面的步骤也将是反向而行。 工具库下载 工欲善其事,必先利其器!这三个库是经过修改压缩的,支持在小程序上使用并且体积可观(总共69.1KB,如果不涉及密码哈希处理只需要前两个库,体积只有65.3KB),接下来的实现操作将会使用到,建议大家可以根据实际情况对功能进行二次封装: CryptoJS.js:点击下载 RSA.js:点击下载 SHA256.js(可选):点击下载 在线的各类加解密工具(可选,可以收藏起来,平时测试挺有用):点击访问 请求数据防重放 要防范请求重放攻击,首先需要了解Unix时间戳 timestamp概念,和时间戳不一样的是它的单位是秒,事实上这个需求也只需要秒级即可。除此之外还将用到另一个值:nonce,它是一个随机产生并只能被使用一次的值,长度自定,请求越频繁长度需要越长(降低同一时间产生相同nonce的几率),C端发送请求时需要将timestamp和生成的nonce加入发送的参数中。那么,如何将两者结合呢?我这为防重放的目标下了一个简单的定义。 同样的请求只能发生一次,且请求必须在规定时间内发出。 何为同样的请求?不是指两次发送的参数一致就是一样的,而是连timestamp和nonce也一样才算是同样的请求。 那么S端如何确认其是同样的请求呢? S端每次接收到一个请求,都会将该请求的nonce存入缓存并保持60秒(这个阈值不一定是60秒,可以根据实际需要定义),时间过后该值将被移除,建议S端采用Redis存储nonce,这样可省去检测和移除nonce的代码。如果S端发现当前请求的nonce存在于已存储的nonce之中,则此请求发生重复,那么timestamp有何用? 如果只使用nonce我们只能保证该请求60秒内不会重复,但60秒后依然任人宰割,这不是要的结果。所以timestamp将用来限制时间,S端时间戳减去C端发送请求的时间戳,得到的差值为N秒,如果N秒大于60秒则此请求过期,那么则可以保证,60秒内因为nonce相同而被判为请求重放,60秒后因为时间差超过而被判为请求已过期,因此确保了请求不会被重放。。 以下展示三种情况: [代码]C端时间戳:1568487720 //2019/9/15 03:02:00 C端NONCE:5rKbMs2Fm3 C端发送请求 -> S端接收请求 S端时间戳:1568487722 //2019/9/15 03:02:02 CS端时间差:1568487722 - 1568487720 = 2 C端NONCE是否存在于缓存:false 【重放校验通过】 [代码] [代码]C端时间戳:1568487722 //2019/9/15 03:05:22 C端NONCE:IzFEs52bAC C端发送请求 -> S端接收请求 S端时间戳:1568487922 //2019/9/15 03:02:00 CS端时间差:1568487922 - 1568487722 = 200 C端NONCE是否存在于缓存:false 【重放校验不通过,请求已过期,因为时间差超过60秒】 [代码] [代码]C端时间戳:1568487720 //2019/9/15 03:02:00 C端NONCE:IxwPHQU0nA C端发送请求 -> S端接收请求 S端时间戳:1568487722 //2019/9/15 03:02:02 CS端时间差:1568487722 - 1568487720 = 2 C端NONCE是否存在于缓存:true 【重放校验不通过,此请求为重放请求,因为nonce已经存在,此请求已经完成,不可重复】 [代码] timestamp和nonce将作为参数参与下面部分的签名。 请求数据防篡改 C端数据签名 首先通过对参数按照参数名进行字典排序(调过一些第三方API的朋友应该明白),假设当前需要传输的参数如下: [代码]{ "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3" } [代码] 进行字典排序,参数名顺序应为: [代码]const keys = Object.keys(data); //获得参数名数组 keys.sort(); //字典排序 console.log(key); //["a", "b", "c", "nonce", "timestamp"]; [代码] 参数字典排序后应和参数一起拼接为字符串,至于使用什么拼接符就要与S端商量了,如果参数值是一个数组或一个对象(如c为[1,2,3])那么可以将数据值转为JSON字符串再拼接。以上参数拼接后字符串如下: [代码]a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720 [代码] 下一步是计算拼接字符串的MD5哈希值了嘛? 不是。因为这样拼接的字符串很容易被攻击者伪造签名并篡改数据,这样就失去了签名的意义了。也就是说缺了一个key值。key值又从何而来?上面思考部分有提到建议从登录后发放,并且这个key与该用户的登录态绑定,登录态有效期间,将使用这个key进行请求的签名与验签,至于如何鉴别用户,我们会在最终发送给S端的参数加入一个sessionId作为登录态唯一标识,这里不加是因为这部分数据是需要参与后续的加密的,而sessionId不参与加密。 但是可能又会有一个问题,登录前没有key怎么实现的登录请求?事实上,登录请求并不怕篡改,因为攻击者自己也不知道账号密码,所以无需提供key用于登录请求的签名。 提到登录密码这个需要注意一点,密码不能明文传输,请计算哈希值后传输,S端比对账户密码哈希值即可确认是否正确,同样S端非特殊情况也不能明文存储密码,建议SHA-1或更高级的SHA-256计算后的值,MD5值可能被使用彩虹表(一种为各种常见密码建立的MD5映射表)破解。SHA256计算的库已在上面工具库下载提供。 拼接上我们登录时随机生成的key: [代码]a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720&key=gUelv79KTcFaCkVB [代码] 接下来计算32位MD5哈希值 为什么上面说MD5会被破解而这里却用MD5计算?因为此处计算MD5的目的并不是为了隐藏明文数据,而只是用于数据校验 此处引入了CryptoJS.js [代码]const CryptoJS = require('./CryptoJS'); const signStr = "a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720&key=gUelv79KTcFaCkVB"; const sign = CryptoJS.MD5(signStr).toString(); //a42af0962de99e698d27030c5c9d3b0e [代码] 这么一来我们的数据签名阶段就完成了,然后需要把签名加入参数之中,将和参数一起传输,需要注意的是传输参数无需在意参数名排序。以下是当前的参数处理结果: [代码]{ "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3", "sign": "a42af0962de99e698d27030c5c9d3b0e" } [代码] 其实从上面这两部分内容来看,会发现防重放和防篡改是相辅相成的,就像两兄弟一样,少了谁都干不好这件事。 S端验证签名 既然有签名那必定也有验签,验签流程其实就是重复C端的前面流程并比对CS两端得出的签名值是否一致。S端取得请求数据后(假设数据未加密,暂时不讨论解密),将除了sign之外的参数名进行字典排序,sign用一个临时变量存下,然后排好序的参数和C端一样拼接得到字符串。 接下来根据上面部分提到的未加密参数sessionId获得用户登录态并获取到该状态的临时key拼接到字符串末尾,接下来进行MD5计算即可得到S端方获得的签名,此时与请求中携带的sign比较是否一致则可确定签名是否有效,如果不一致返回签名错误。具体流程请参考以下: [代码]C端发送请求 -> S端接收请求 //请求参数为data const _sign = data.sign; //排序并拼接除sign外的参数 let signStr = a=789&b=456&c=123&nonce=5rKbMs2Fm3×tamp=1568487720 const sessionId = ...; //用户的sessionId const sessionData = getUserSessionData(sessionId);//根据sessionId查询用户登录态sessionData并取出key,并拼接到字符串尾部 const key = sessionData.key signStr += key; //MD5计算并比对,代码仅供参考 const sign = crypto.md5(signStr); if(sign !== _sign) { //签名错误! } [代码] 请求数据加解密 有了上面部分的参数处理铺垫后,接下来就该开始本文章最核心的加解密,在这之前我们先了解AES和RSA两种加密算法。了解概念后我们再来思考一下两者的一些特性。 AES加解密需要密钥,除某些模式外还需要提供初始化向量,可使用密钥解出明文,是对称加密算法。 RSA加解密需要一对密钥,分别为公钥和私钥,公钥加密,私钥解密,是非对称加密算法。 两者在加密长文本性能上AES占优势。 根据这些让我们发现,他们可以形成互补关系,RSA加解密安全性高但长文本处理性能不及AES。AES加解密长文本性能优于RSA但需要明文密钥和向量加解密,密钥的安全性成问题。 那么在C端生成随机的AES密钥和向量,使用密钥和向量使用aes-128-cbc加密模式(也可以根据实际需要采用其它的模式)加密真正需要传输的参数(参数则是经过防重放+防篡改处理的参数)得到encryptedData,然后将该密钥使用RSA公钥加密得到encryptedKey,下面我顺便把AES加密的向量也一起加密了得到encryptedIV,这样就完美的互补了对方的缺点,既能够较快的完成数据加密又能保证密钥安全性,两全其美。 整个个加解密流程如下图所示: [图片] 下面的四个小章节将逐一描述流程实现: C端加密数据 C端加密后的数据应如下(并非固定格式,根据自己需要定制): [代码]{ "sessionId": "xxxxxxxx", "encryptedData": "xxxxxx", "encryptedKey": "xxxxxx", "encryptedIV": "xxxxxxx" } [代码] 但RSA公钥又是怎么发放到C端的呢?答案是在登录认证的时候服务器下发的,登录成功时服务器会创建RSA密钥对并把公钥发放给C端,私钥存在服务器上该用户的登录态数据中。流程如下所示: [代码]C端发起登录请求 -> S端接收登录请求 S端登录认证是否通过 true S端生成RSA密钥对 - publicKey , privateKey S端查询相关用户信息 S端生成登录态信息 -> 向登录态信息存入privateKey私钥,登录态信息类似如下 "xxxxxx": { id: "xxxxx", authData: { key: "xxxxxx", privateKey: "xxxxxx" } } S端返回登录态唯一标识sessionId和publicKey公钥以及相关用户信息 -> C端 C端存储登录态信息于本地,后续请求将使用服务器提供的公钥进行加密 [代码] 其中privateKey就是该用户当前登录态所使用的解密私钥,C端通过公钥加密后的AES密钥数据只能用该私钥解密。 如果希望登录阶段的请求也加密,那么可以手动生成一个RSA密钥对,然后客户端放置一个固定的公钥,服务器也使用一个固定的私钥进行登录阶段的加解密。 具体实现如下: 此处引入了CryptoJS.js和RSA.js [代码]const CryptoJS = require('./CryptoJS'); const RSA = require('./RSA.js'); //假设当前已登录成功并获得S端下发的RSA公钥且已存入本地存储 //createRandomStr为生成随机大小写英文和数字的字符串 const aesKey = createRandomStr(16); //生成AES128位密钥 16字节=128位 const aesIV = createRandomStr(16); //生成初始化向量IV const raw = JSON.stringfly({ "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3", "sign": "a42af0962de99e698d27030c5c9d3b0e" }); const encryptedData = CryptoJS.AES.encrypt(data: raw, key: aesKey, { iv: aesIV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); //使用CBC模式和Pkcs7填充加密 const authData = wx.getStorageSync("authData"); //读取本地存的RSA公钥 RSA.setPublicKey(authData.publicKey); //设置RSA公钥 const encryptedKey = RSA.encrypt(aesKey); //RSA加密AES加密密钥 const encryptedIV = RSA.encrypt(aesIV); //RSA加密AES加密初始化向量,是否加密向量可由自己决定 //最后的处理结果 const result = { sessionId: authData.sessionId, encryptedData, encryptedKey, encryptedIV }; [代码] S端解密数据 [代码]const crypto = require('crypto'); const cryptojs = require('crypto-js'); const { sessionId, encryptedData, encryptedKey, encryptedIV } = requestData; const authData = getUserSessionData(sessionId); //获取用户登录态数据 const privateKey = authData.privateKey; const aesKey = crypto.privateDecrypt({ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, encryptedKey); //使用私钥解密得到AES密钥 const aesIV = crypto.privateDecrypt({ key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, encryptedIV); //使用私钥解密得到AES iv向量 const key = cryptojs.enc.Base64.parse(aesKey); const iv = cryptojs.enc.Utf8.parse(aesIV); const decryptedData = cryptojs.AES.decrypt(encryptedData, key, { iv, mode: cryptojs.mode.CBC, padding: cryptojs.pad.Pkcs7 }); //采用与加密统一的模式和填充进行解密 const { "c": 123, "b": 456, "a": 789, "timestamp": 1568487720, "nonce": "5rKbMs2Fm3", "sign": "a42af0962de99e698d27030c5c9d3b0e" } = decryptedData; //至此解密得到C端传来的数据 [代码] S端加密数据 当S端处理完C端的请求后应加密响应数据,那么加密响应数据应该使用什么密钥呢?既然C端已经将加密的密钥发送过来了,那么干脆将C端使用的AES密钥拿来加密响应数据就可以了。加密的数据传回C端后,C端只需使用该请求所使用的AES加密密钥进行解密即可。响应的加密数据如下: [代码]const cryptojs = require('crypto-js'); const responseData = JSON.stringify(...); //此为S端需要返回给C端的数据 const aesKey = ...; //此为之前C端用来加密数据的AES密钥 const aesIV = createRandomStr(16); //生成初始化向量IV const encryptedData = cryptojs.AES.encrypt(data, aesKey, { iv: aesIV, mode: cryptojs.mode.CBC, padding: cryptojs.pad.Pkcs7 }); //加密响应数据 const encryptedResponse = { "encryptedData": encryptedData, "iv": aesIV }; //得到加密后的响应数据并返回给C端 [代码] C端解密数据 C端接收到S端的响应数据后应对加密的数据进行解密,此次解密就是单纯的AES解密了,使用发起请求时用于加密数据的AES密钥配合响应数据的iv向量对encryptedData进行解密,得到解密后的数据即为S端真正的响应数据。实现过程如下: 此处引入了CryptoJS.js [代码]const CryptoJS = require('./CryptoJS'); const key = ...; //之前用于加密请求参数的AES加密密钥 const { encryptedData, iv } = responseData; const aesIV = CryptoJS.enc.Utf8.parse(iv); const aesKey = CryptoJS.enc.Utf8.parse(key); const decryptedData = CryptoJS.AES.decrypt(encryptedData, aesKey, { iv: aesIV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); //AES解密响应数据 const { ... } = decryptedData; //得到解密后的响应数据 [代码] 结语 第一次写这么长的文章,可能存在大量纰漏,如果大佬发现问题欢迎指出(`・ω・´)我会马上修改。 也许小程序的运行环境会比我想象中的更安全 也许HTTPS也会比我想象中的更安全 也许Web服务器引擎也比我想象中的更安全 但,安全不总是先行一步的吗?
2019-10-24 - regeneratorRuntime 相关报错排查指引
regeneratorRuntime is not defined Can't find variable: regeneratorRuntime 该问题是由于 babel 编译时未能正确注入 regeneratorRuntime 导致的。 babel 会将高版本js语法,转换为低版本兼容的语法。regeneratorRuntime 是 babel 在对 js 中 async/await 语法进行转换时,会使用的 runtime 依赖。开发者工具的【将 JS 编译成 ES5】即是使用 babel 进行编译。 可以先尝试更新开发者工具和 miniprogram-ci 到最新版后,开启开发者工具的【将 JS 编译成 ES5】编译选项。miniprogram-ci 需要开启 es7: true (增强编译)选项。注意若使用第三方框架已经进行了 babel 编译;或者自行使用了 babel 编译,则不需要开启工具和 miniprogram-ci 的编译选项。 若使用了第三方框架,或者自行配置的 babel 配置,则需要检查是否正确注入 regeneratorRuntime。常见的解决方案是为 babel 配置添加插件 @babel/plugin-transform-runtime。 Function("r", "regeneratorRuntime = r") is not a function 该问题通常是正确引入了 regeneratorRuntime,但是误将 regeneratorRuntime 编译至了严格模式(即含有 'use strict' )。 regeneratorRuntime 由于会尝试通过不使用 var 关键字直接赋值的方式,来声明全局 regeneratorRuntime 变量。这种方式在严格模式下会报错,因而尝试使用 new Function 的形式从严格模式跳入非严格模式,再次进行全局变量声明。然而小程序环境下不允许使用 new Function,因而初始化失败。 该问题通常是由于重复打包/编译导致的,即第一次编译时将 regeneratorRuntime 识别为非严格模式,而第二次编译时由于失去原始信息,识别为严格模式。 需要排查打包路径是否重复经过了 babel 或 webpack 等编译或打包。若使用第三方框架已经进行了 babel 编译;或者自行使用了babel编译,则需要关闭开发者工具的【将 JS 编译成 ES5】编译选项和 miniprogram-ci 的 es7: true (增强编译)选项。 我的小程序并没有使用 async/await 语法,但也报错 如果你的小程序引入了插件,而有插件没有正确配置编译模式,同样会出现上述问题。建议排查是否是插件导致。如果确认是插件导致,则需要插件提供方参考本指引解决问题。 另外的,npm 模块、第三方框架等同样可能引入 regeneratorRuntime 依赖。 我的小程序在正式版没有以上问题,但开发者工具/开发版预览/体验版会有上面的报错 该问题是由于基础库 bug,在 2.21.3 版本之前误将 regeneratorRuntime 暴露到了全局,因而导致小程序即使没有正确注入 regeneratorRuntime,但也能够正常运行。 基础库 2.21.3 版本已将 regeneratorRuntime 从工具、开发版、预览版小程序&小游戏上移除,因此会看到报错。你可以参照上面的排查指引进行排查直至解决问题。 基础库暂时没有移除正式版小程序的 regeneratorRuntime,因此你不用担心线上正式版小程序,也可以不用紧急修复该问题。 我的小程序只在某些低版本微信上出现上面的报错 低版本的基础库如 2.10.4 还没有误将 regeneratorRuntime 暴露到全局,因此只会在低版本出现该问题。 建议参照上面的排查指引进行排查直至修复解决问题。
2022-01-17 - 微信人脸核身接口能力
一、能力背景 近年来,国家在医疗挂号、APP注册、快递收寄、客运、运营商等多领域规定,需要用户实名才可办理业务,预计后续也会有越来越多的此类法规。因此,微信参照公安部“互联网+”可信身份认证服务平台标准,依托腾讯公司及微信的生物识别技术,建立微信“实名实人信息校验能力” ,即通过人脸识别+权威源比对,校验用户实名信息和本人操作(简称微信人脸核身)。 目前接口限定主体及行业类目开放公测,提供给资质符合要求的业务方,在合适的业务场景内使用。目前仅支持持二代身份证的大陆居民。 由于人脸核身功能涉及到用户的敏感、隐私信息,因此调用此接口的小程序,需要满足一定的条件。即:小程序的主体以及类目,需要在限定的类目范围内,且与小程序的业务场景一致。开展的业务也需要是国家相关法规、政策规定的需要“实名办理”的相关业务(其他未在范围内的业务,则暂不支持)。 以下为接口接入及开发的详细内容。如开发中遇到任何疑问,可以点击此处通过社区反馈,将有工作人员跟进回复。 文档第四部分【再次获取核验结果api】,有助于提高业务方安全性,请务必接入! 现阶段微信人脸核验能力,针对小程序,开放的主体类目范围包含: 小程序一级类目 小程序二级类目 小程序三级类目 使用人脸核验接口所需资质 物流服务 收件/派件 / 《快递业务经营许可证》 物流服务 货物运输 / 《道路运输经营许可证》(经营范围需含网络货运) 教育 学历教育(学校) / (2选1):1、公立学校:由教育行政部门出具的审批设立证明 或 《事业单位法人证书》;2、私立学校:《民办学校办学许可证》与《民办非企业单位登记证书》 医疗 公立医疗机构 / 《医疗机构执业许可证》与《事业单位法人证书》 医疗 互联网医院 / 仅支持公立医疗机构互联网医院(2选1):1、卫生健康部门的《设置医疗机构批准书》;2、 《医疗机构执业许可证》(范围均需含“互联网诊疗”或名称含“互联网医院”等相关内容 医疗服务 三级私立医疗机构 / 仅支持三级以上私立医疗机构,提供《医疗机构执业许可证》、《营业执照》及《医院等级证书》 政务民生 所有二级类目 / 仅支持政府/事业单位,提供《组织机构代码证》或《统一社会信用代码证》。 金融业 银行 / (2选1):1、《金融许可证》; 2、《金融机构许可证》。 金融业 信托 / (2选1):1、《金融许可证》; 2、《金融机构许可证》。 金融业 公募基金 / (4选1):1、《经营证券期货业务许可证》且业务范围必须包含“基金”;2、《基金托管业务许可证》; 3、《基金销售业务资格证书》;4、《基金管理资格证书》。 金融业 证券/期货 / 《经营证券期货业务许可证》 金融业 保险 / (8选1):1、《保险公司法人许可证》;2、《经营保险业务许可证》;3、《保险营销服务许可证》;4、《保险中介许可证》;5、《经营保险经纪业务许可证》;6、《经营保险公估业务许可证》或《经营保险公估业务备案》;7、《经营保险资产管理业务许可证》 ;8、《保险兼业代理业务许可证》。 金融业 消费金融 / 银监会核准开业的审批文件与《金融许可证》与《营业执照》 金融业 汽车金融/金融租赁 / 仅支持汽车金融/金融租赁主体,同时提供:1、《营业执照》(公司名称包含“汽车金融” /“金融租赁”;营业范围包含“汽车金融”/“金融租赁”业务);2、《金融许可证》或银保监会及其派出机构颁发的开业核准批复文件。 交通服务 网约车 快车/专车/其他网约车 (自营性网约车)提供《网络预约出租汽车经营许可证》。(网约车平台)提供与网约车公司的合作协议以及合作网约车公司的《网络预约出租汽车经营许可证》。 交通服务 航空 / (航司)提供《公共航空运输企业经营许可证》。(机场)提供《民用机场使用许可证》或《运输机场使用许可证》。 交通服务 公交/地铁 / 提供公交/地铁/交通卡公司《营业执照》 交通服务 水运 / (船企)提供《水路运输许可证》。(港口)提供《港口经营许可证》 交通服务 骑车 / 仅支持共享单车,提供共享单车公司《营业执照》 交通服务 火车/高铁/动车 / 仅支持铁路局/公司官方,提供铁路局/公司《营业执照》 交通服务 长途汽车 / (2选1):1、《道路运输经营许可证》(经营范围需含客运);2、官方指定联网售票平台(授权或协议或公开可查询文件)。 交通服务 租车 / 运营公司提供《备案证明》与对应公司《营业执照》,且营业执照中包含汽车租赁业务 交通服务 高速服务 / 仅支持ETC发行业务,(2选1):1、事业单位主体,需提供《事业单位法人证书》;2、官方指定的发行单位(一发单位),需提供“官方授权或协议,或公开可查询的文件”; 生活服务 生活缴费 / (供电类)提供《电力业务许可证》与《营业执照》,且《营业执照》且经营范围含供电。(燃气类)提供《燃气经营许可证》与《营业执照》,且《营业执照》且经营范围含供气。(供水类)提供《卫生许可证》与《营业执照》。(供热类)提供《供热经营许可证》与《营业执照》,且《营业执照》且经营范围含供热。 IT科技 基础电信运营商 / (2选1):1、基础电信运营商:提供《基础电信业务经营许可证》;2、运营商分/子公司:提供营业执照(含相关业务范围)。 IT科技 转售移动通信 / 仅支持虚拟运营商,提供《增值电信业务许可证》(业务种类需含通过转售方式提供移动通信业务) 旅游服务 住宿服务 / 仅支持酒店,提供《酒店业特种行业经营许可证》 商业服务 公证 / 仅支持公证处,提供《公证处执业许可证》或《事业单位法人证书》 社交 直播 / (2选1):1、《信息网络传播视听节目许可证》;2、《网络文化经营许可证》(经营范围含网络表演)。 如对以上类目或资质有疑问,可点击参考小程序“非个人主体开放的服务类目”,详细了解小程序开放的服务类目及对应资质。 二、准备接入 (请在小程序发布后,再提交人脸核身接口申请) 满足第一节中描述的类目和主体的小程序,可申请微信人脸核验接口。目前微信人脸核身接口已改为线上自助申请方式,需按照如下图例指引,进行接口申请: 第一步:请通过mp.weixin.qq.com登录小程序账号在后台“功能-人脸核身”的路径,点击开通按钮—— [图片] 第二步:仔细查阅《人脸识别身份信息验证服务条款》后,点击“同意并下一步”—— [图片] 第三步:请正确填写服务信息,并上传该小程序类目下所要求的资质—— [图片] 第四步:请按照业务实际需求填写使用人脸接口的场景和用途—— [图片] 第五步:请完善测试信息和联系人—— [图片] 第六步:提交后请耐心等待1-3个工作日的审核期,审核结果将以站内信通知—— 如申请期间遇到问题,可联系腾讯工作邮箱 wx_city@tencent.com,将会有相关工作人员进一步指引。 三、接口文档: (一)接口描述 名称: wx.startFacialRecognitionVerify(OBJECT) 功能:请求进行基于生物识别的人脸核身 验证方式:在线验证 兼容版本: 一闪:android 微信7.0.22以上版本, iOS 微信7.0.18以上版本 建议在微信官网升级至最新版本 (二)参数说明 1、OBJECT参数说明: 参数 类型 必填 说明 name String 是 姓名 idCardNumber String 是 身份证号码 success Function 否 调用成功回调 fail Function 否 调用失败回调 complete Function 是 调用完成回调(成功或失败都会回调) 2、CALLBACK返回参数 参数 类型 说明 errMsg String 错误信息 errCode Number 错误码 verifyResult String 本次认证结果凭据,第三方可以选择根据这个凭据获取相关信息 注 1:传递用户姓名和身份证有两种方式 业务方没有用户实名信息,用户需要在前端填写身份证和姓名,那么前端直接通过jsapi 调用传递 name 和 idCardNumber。 业务方已经有用户实名信息,后台通过微信提供的 api(详情见文档后面“上传姓名身份证后台 api”)上传用户身份证姓名和身份证,api 返回 user_id_key 作为凭证传给前端,前端再调用 jsapi,用户姓名、身份证信息不需要经过前端,参数只需要传递 userIdKey。Tips:使用该功能需要小程序基础库版本号>=1.9.3。 3、回调结果说明 回调结果请参考以下释义: [图片] [图片] [图片] 4、示例代码 [图片] [图片] (三)上传用户姓名身份证的后台api 1、API说明 1.1说明 业务方上传用户姓名和身份证,获取用户凭证,把凭证给到前端通过 jsapi 调用。 Tips :使用该功能需要小程序基础库版本号>=1.9.3。 1.2请求URL https://api.weixin.qq.com/cityservice/face/identify/getuseridkey?access_token={ac cess_token} 1.3请求方式 POST 2、请求数据格式 [代码]Json { "name" : “张三”, "id_card_number" : "452122xxxxxxx43215" } [代码] 请求示例 [代码]#!/bin/bash TOKEN='xxxxxxxxxxxx' URL='https://api.weixin.qq.com/cityservice/face/identify/getuseridkey' JSON='{ "name": "张三", "id_card_number": "452344xxxxxxxxxxxxx234"}' curl "${URL}?access_token=${TOKEN}" -d "${JSON}" [代码] 参数说明 json 字段 中文显示 是否必传 name 姓名 是 id_card_number 身份证号码 是 out_seq_no 业务方唯一流水号 否 3、返回数据 参数 类 型 说明 errcode int 错误码 errmsg string 错误信息 user_id_key string 用于后台交互表示用户姓名、身份证的凭证 expires_in uint32 user_id_key 有效期,过期需重新获取 [代码]{ "errcode" : 0, "errmsg" : "ok", "user_id_key" : "id_key_xxxx", "expires_in": 3600 } [代码] 4、后台消息推送 如果业务方传入out_seq_no,核身完成后会通过消息推送回调给业务方的服务器,如果回调业务方失败,会在5s尽力推送,超过5s不再推送。 参数说明 参数 类 型 说明 ToUserName string 小程序原始ID FromUserName string 事件消息openid CreateTime uint32 消息推送时间 MsgType string 消息类型 Event string 事件类型 openid string 核身用户的openid out_seq_no string 业务方唯一流水号 verify_result string 核身返回的加密key(凭据) 返回示例 [代码]{ "ToUserName": "gh_81fxxxxxxxx", "FromUserName": "oRRn15NUibBxxxxxxxxx", "CreateTime": 1703657835, "MsgType": "event", "Event": "face_identify", "openid": "oRRn15NUibBxxxxxxxxx", "out_seq_no": "test1234", "verify_result": "XXIzTtMqCxwOaawoE91-VNGAC3v1j9MP-5fZJxv0fYT4aGezzvYlUb-n6RWQa7XeJpQo0teKj8mGE4ZcRe1JI3GqzADBYORBu613rKjKAFfEXTXw_bu1bs7MnmPOpguS" } [代码] 四、再次获取核验结果api 此接口是前端完成人脸核身后,基于前端返回的凭据,通过后台api再次进行核验结果和身份信息的校验,有助于提高安全性,请务必接入! 前端获取结果不可信,存在被篡改的风险,为了保障请求结果安全性,请务必对identify_ret、id_card_number_md5、name_utf8_md5字段进行校验! (一)API说明 1、说明 人脸核身之后,开发者可以根据jsapi返回的verify_result向后台拉取当次认证的结果信息。 2、请求URL https://api.weixin.qq.com/cityservice/face/identify/getinfo?access_token={access_token} 3、请求方式 POST 4、请求格式 json (二)请求数据说明 1、请求 参数 类型 是否必填 描述 verify_result String 是 jsapi返回的加密key(凭据) 2、数据返回 HTTP 头如下 Date: Mon, 06 Feb 2017 08:12:58 GMT Content-Type: application/json; encoding=utf-8 Content-Length: 85 Connection: close json示例 [代码]{ "errcode" : 0, [代码] [代码]"errmsg" : "ok", "identify_ret" : 0, "identify_time" : 1486350357 "validate_data": "8593" [代码] [图片] (三)返回参数说明 1、返回参数 注:errcode和identify_ret同时为0,代表本次认证成功。 参数 类型 描述 errcode int 错误码, 0表示本次api调用成功 errmsg string 本次api调用的错误信息 identify_ret int 人脸核身最终认证结果 identify_time uint32 认证时间 validate_data string 用户读的数字(如是读数字) openid string 用户openid user_id_key string 用于后台交互表示用户姓名、身份证的凭证 finish_time uint32 认证结束时间 id_card_number_md5 string 身份证号的md5(最后一位X为大写) name_utf8_md5 string 姓名MD5 2、错误码对应信息 errcode 备注 84001 非法identity_id 84002 用户信息过期 84003 用户信息不存在 五、小程序辅助接口:检查设备是否支持人脸检测 1、接口名称 接 口 :wx.checkIsSupportFacialRecognition(OBJECT) 功能:检查设备是否支持人脸检测 2、接口说明和使用 小程序调用该接口,可以检测当前手机设备是否具备支持人脸检测的能力,可与以上接口分开使用,为了用户体验,建议调用后对手机设备不支持的用户做对应功能处理。 3、接口说明和使用 01 OBJECT 参数说明: 参数 类型 是否必填 描述 success Function 否 调用成功回调 fail Function 否 调用失败回调 complete Function 是 调用完成回调(成功或失败都会回调) checkAliveType Number 否 人脸核验的交互方式,默认读数字(见表 2) 表 2:checkAliveType 的值和对应的解释: 参数 解释 2 先检查是否可以屏幕闪烁,不可以则自动为读数字 02 CALLBACK 返回参数 参数 类型 说明 errMsg Boolean 错误信息 errCode Number 错误码 03 回调结果说明 回调类型 ErrCode 说明 sucess 0 支持人脸采集 fail 10001 不支持人脸采集:设备没有前置摄像头 fail 10002 不支持人脸采集:没有下载到必要模型 fail 10003 不支持人脸采集:后台控制不支持 回调结果说明仅对Android生效,iOS不返回errcode。 04 示例代码 [图片] 六、安全性说明 为保障业务可用性以及安全性,请详细研读微信人脸核身接口相关基础说明及安全说明文档:https://docs.qq.com/doc/DTFB0YWFIdGV6amly 备注:如开发中遇到任何疑问,可以点击此处通过社区反馈,将有工作人员跟进回复。 七、案例展示及补充说明 安徽医科大学第二附属医院,微信人脸核验登录: 安徽医科大学第二附属医院,是三级甲等综合医院。其小程序为用户提供挂号、门诊费用、住院费用、检查报告、体检等医疗服务,同时也提供停车、餐饮等便民服务,是医疗小程序中完整的案例。 小程序使用了微信人脸核验能力作为登录的核验。满足医院管理要求,也满足国家对于实名就医的管理规则。 案例实现的截图效果如下: [图片] [图片] 针对近期少数小程序方面反馈的两类问题,也在本课程进行补充说明。 1、本接口的开放范围,即:可支持的主体类目,是否可以扩大? 说明:基于本接口整体使用范围的评估、相关法规的参考、监管策略的理解执行等,暂时未立刻进行扩大开放范围的工作。 但我们会持续基于不同行业的法规、政策及监管要求等,逐一进行研究考量,以便确认如何扩大开放范围。 2、小程序如果涉及用户本人的生物特征采集,(如本人人脸照片、人脸视频),或涉及采集用户本人生物特征信息并开展人脸核验功能,则存在被驳回的情况? 说明:近两年“人脸识别”技术在社会上掀起了热潮。人脸识别虽然作为摆脱“中间媒介”或“承载载体”的一种直接技术手段,解决了部分政务、交通、医疗、零售等证明“操作者是本人”的问题,但也因此,引入了新的更大的安全风险。 一是,虚假安全风险。 身份认证领域的安全三因素包括“我知道什么”、“我拥有什么”、“我的特征是什么”,通用的安全做法,是要双因素认证(2FA),人脸识别技术如仅凭“我的特征是什么”这一个因素,则容易被攻破或利用。表象给用户以安全的感觉,但实际并不能达到安全效果。 二是,信息泄漏的风险。 越来越多的组织或个人,在并非必需用户敏感信息、生物特征的情况下,采集并存储此类信息。在信息加密、传输、存储过程中,容易暴漏更多的网络节点,使得此类信息有更大的风险被网络黑客拦截、窃听、窃取,或直接被脱库。 三是,消除风险的难度大。 以往基于“中间媒介”或“承载载体”的方式,如出现丢失、被冒用、恶意盗用等风险,可以通过挂失、更换、使用新载体或新媒介等方式,快速排除一定的风险。C端主动,B端主动,都能解决一部分问题。但人脸识别做为更直接的方式,一旦出现冒用、盗用,受害者将面临更大的财产及人生安全风险,且C端用户更多时候无法主动消除风险。 基于以上问题风险,加之国家出台《网络安全法》、《用户隐私保护条例》等法律法规标准,网信办、公安部、工信部及市场监管总局等四部委发起的app获取隐私整治,结合平台安全、用户敏感隐私信息保护要求及监管,针对部分暂无相关法规或要求,需要采集或生物认证方式进行身份核验的,或以“追热点”或“尝鲜”为目的,采集用户生物特征或进行身份核验的,进行严格审核,必要时不予以支持。
星期一 15:19 - 如何只使用一个云函数搞定一个庞大而复杂的系统
吐槽 翻遍社区的文章,关于云开发的干货,少之又少,大部分都还是官方文档的搬来搬去,没啥营养,是时候放出一点技术"干货"了(有经验的开发者都能想到的方案)! 正题 小程序云开发的云函数的最大限制是 [代码]50[代码] 个,假设每个接口都使用 [代码]1[代码] 个云函数的话,有 [代码]10[代码] 张表,每张表都有 [代码]增删改查[代码] 四个接口,那么就会有 [代码]40[代码] 个接口,再加上一些其他接口,差不多刚刚好够用,那如果有 [代码]20[代码] 张表,甚至更多的表、更多的接口呢?对于中小型的小程序来说足够使用,那如果一个非常庞大而复杂的系统该怎么办呢? 而且每一个云函数的运行环境是独立的,想要共享一些数据也不是特别方便,那么有没有什么办法突破这样的限制呢? 其实解决方案很简单,只需要一点点的 [代码]OOP[代码] 思想和利用 [代码]JavaScript[代码] 的特性,一个云函数就可以搞定所有的接口。 具体的实现请往下看。 思路 云函数的运行环境是 [代码]Nodejs[代码] , 那么使用的语言就是 [代码]JavaScript[代码] ,可以充分的利用 [代码]JavaScript[代码] 的特性。 [代码]JavaScript[代码] 中的 [代码]属性访问表达式[代码] 有两种语法 [代码]expression . identifier expression [ expression ] [代码] 第一种写法是一个表达式后跟随一个句点 [代码].[代码] 和一个标识符。表达式指定对象,标识符则指定需要访问的属性的名称。 第二种写法是使用方括号 [代码][][代码],方括号内是另一个表达式(这种方法适用于对象和数组)。第二个表达式指定要访问的属性的名称或者代表要访问数组元素的索引。 不管使用哪种形式的属性访问表达式,在 [代码].[代码] 和 [代码][][代码] 之前的表达式总是会首先计算。 虽然 [代码].[代码] 的写法更加简单,但这种方式只适用于要访问的属性名称的合法标识符,并需要准确知道访问的属性的名字,如果属性的名称是一个保留字或者包含空格和标点符号,或者是一个数字(对于数组来说),则必须使用方括号 [代码][][代码] 的写法。当属性名是通过运算得出的值而不是固定值的时候,这时也必须使用方括号 [代码][][代码] 写法。 感谢社区大神 @卢霄霄 提供参考资料,详见 [代码]JavaScript权威指南[代码] (犀牛书)4.4章节。 可以使用 [代码][][代码] 的形式来完成动态的属性访问。具体实现请往下看。 实现 上面说了太多废话了,下面直接开干吧。 新建云函数 在云开发目录中新建一个云函数,我这里命名为 [代码]cloud[代码]。 打开 [代码]index.js[代码] 文件你会看到下面这段代码 [代码]// 云函数入口文件 const cloud = require('wx-server-sdk') // 初始化 cloud cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) // 云函数入口函数 exports.main = async (event, context) => { const wxContext = cloud.getWXContext() return { event, openid: wxContext.OPENID, appid: wxContext.APPID, unionid: wxContext.UNIONID, } } [代码] 这个云函数仅作为入口使用,上面提到了云函数的运行环境是 [代码]Nodejs[代码] 那么 [代码]Nodejs[代码] 的特性也是可以使用的,这里主要用到的是全局对象 [代码]global[代码],详见文档 在文件中,写入一些必要的全局变量,主要还是云数据库方面的,方便后面使用。 在初始化后面插入代码 [代码]global.cloud = cloud global.db = cloud.database() global._ = db.command global.$ = _.aggregate [代码] 这样就可以在同一个云函数环境中直接访问这些全局变量。 创建公共类 然后新建一个文件夹,我这里命名为 [代码]controllers[代码] ,这个文件夹用于存放所有的接口。 在 [代码]controllers[代码] 中新建一个 [代码]base-controller.js[代码] 文件,创建一个叫做 [代码]BaseController[代码] 的类,用于提供一些公用的方法。 内容如下: [代码]class BaseController { /** * 调用成功 */ success (data) { return { code: 0, data } } /** * 调用失败 */ fail (erroCode = 0, msg = '') { return { erroCode, msg, code: -1 } } } module.exports = BaseController [代码] 看到这里大家可能有点没看懂在做什么,那么请继续往下看。 创建接口 假设创建一些要操作用户相关的的接口,可以在 [代码]controllers[代码] 文件夹中新建一个 [代码]user-controller.js[代码] 的文件,创建一个名为 [代码]UserController[代码] 的类,并继承上面的 [代码]BaseController[代码] 类,内容如下: [代码]const BaseController = require('./base-controller.js') class UserController extends BaseController { // ... } module.exports = UserController [代码] 可以在这个类中编写所有关于 [代码]user[代码] 的接口方法。 编写接口 假设要分页查询用户信息,可以在 [代码]UserController[代码] 类中创建一个 [代码]list[代码] 方法。 代码如下: [代码]async list (data) { const { pageIndex, pageSize } = data let result = await db.collection('users') .skip((pageIndex - 1) * pageSize) .limit(pageSize) .get() .then(result => this.success(result.data)) .catch(() => this.fail([])) return result } [代码] 由于上面已经定义了全局变量 [代码]db[代码] 所以在 [代码]UserController[代码] 中无需引入 [代码]wx-server-sdk[代码] 引入接口类 写到这里接口已经完成了,还需要再引入这些接口类才可以进行访问。在 [代码]index.js[代码] 中引入 [代码]user-controller.js[代码] [代码]const User = require('./controllers/user-controller.js') [代码] 然后创建一个 [代码]api[代码] 变量,[代码]new[代码] 一个 [代码]User[代码] 实例 [代码]const api = { user: new User() } [代码] 在 [代码]main[代码] 方法中调用 [代码]UserController[代码] 中的方法。 [代码]exports.main = async (event, context) => { const { data } = event let result = await api['user']['list'](data) return result } [代码] 写到这里基本已经完成了接口的调用,但想要一个云函数动态调用所有接口还需要做一些改动。 动态调用接口 刚开始的时候介绍了 [代码]属性访问表达式[代码],限制稍微改动一下 [代码]main[代码] 方法 [代码]exports.main = async (event, context) => { const { controller, action, data } = event const result = await api[controller][action](data) return result } [代码] 在小程序调用云函数时,需要传入 [代码]controller[代码]、[代码]action[代码] 和 [代码]data[代码] 参数即可 [代码]const result = await wx.cloud.callFunction({ name: 'cloud', data: { controller, action, data } }) [代码] 完整 [代码]index.js[代码] 文件的代码 [代码]// 云函数入口文件 const cloud = require('wx-server-sdk') const User = require('./controllers/user-controller.js') const api = { user: new User() } // 初始化 cloud cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) global.cloud = cloud global.db = cloud.database() global._ = db.command global.$ = _.aggregate // 云函数入口 exports.main = async (event, context) => { exports.main = async (event, context) => { const { controller, action, data } = event const result = await api[controller][action](data) return result } } [代码] 其他实现 云开发官方团队打造的轮子 tcb-router
2020-05-26