通过前几章小程序的UI实践,我们小黄书的Home页面已经有一个基本的呈现。但是,所有的数据还都只是mock模拟的数据,还没有任何跟小黄书服务器进行交互的动作。

今天开始,我们会逐步增加小程序和服务器的交互。首先,我们会从后台对客户端会员登录的支持开始。

参考小红书应用,它大体支持以下几种会员的登录方式:

  • 通过手机号和验证码进行登录
  • 通过手机号和密码进行登录,其中密码是在后期绑定进去,而不是注册时设定的。比如先通过手机号和验证码进行登录,然后再对密码进行绑定,下次就能通过手机号和密码进行登录了。
  • 通过第三方授权进行登录,比如微信,新浪微博等

因为我们现在的客户端只有小程序,ios和android的客户端将会在往后进行实现,所以我们第一步先不会实现所有这些会员登录方式。

这里会先对以下两种会员登录方式进行实现:

  • 通过手机号和验证码进行登录
  • 通过小程序进行微信授权登录

需要注意的是,我们这里并不需要特意的区分开注册和登录。无论是哪种登录方式,只要是之前没有注册过的,都会自动进行注册,然后进行登录。

1. 会员管理


要实现会员登录,我们首先需要在服务器上支持上会员的操作,比如增删改查。

1.1. 会员mongoose模型及静态方法

首先,参考我们之前对User的支持,我们需要定义好会员这个mongoose的实体模型。

const MemberSchema = new Schema({nickname: { type: String, default: '小黄人', required: true },   // 名字。会员昵称password: String,avatar: String,                                                 // 头像realname: String,                                               // 姓名birth: String,                                                  // 生日gender: String,                                                 // 性别。可选值:男, 女address: String,                                                // 地址status: { type: String, default: 'registered' },                // 会员状态。可选值:registered|cancelled(已注册|已注销)bindings: {type: {wechat: {type: {nickname: String,avatar: String,openid: String,country: String,province: String,city: String,},required: false,},mobile: {type: {number: String,},required: false,},},select: false,},
}, {timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' },
});

这里我们除了定义会员的姓名地址等信息之外,最重要的是还定义了两个绑定信息:一个是微信的绑定信息,另外一个就是手机的绑定信息。分别代表了我们上面提及的两种会员登录的情况。

  • 如果我们是通过手机号和验证码注册的,那么一开始的时候我们只有绑定的手机号信息。在后期我们可以绑定密码等信息,这样的话,会员就可以通过手机号和密码直接登录,而不需要每次都通过手机号和验证码进行登录了。因为用手机进行注册的时候,没有携带任何用户的其他信息,所以这里我们默认将昵称设置成“小黄人”。同时,在ios或者安卓客户端,我们还可以在通过手机号码登录后,和微信进行绑定。但这要等到我们事先这些客户端时再进行实现。

  • 如果我们是通过微信小程序进行注册的,那么一开始的时候我们只有绑定的微信用户相关的信息。在后期我们可以绑定手机号等信息,这样在其他客户端进行手机号登录的时候,就能和微信小程序这边的登录统一起来。同时,因为微信登录的时候我们可以获得微信的头像和昵称等信息,所以我们会将绑定的微信用户的昵称等信息同步到跟bindings字段同级的昵称等信息。这样一来,当其他客户端进行登录时,就能自动同步头像和昵称等信息。

同时,我们为会员的schema定义两个静态的方法,以便更方便的通过会员的手机号或者微信的openid来获得会员的信息:


MemberSchema.statics.findByOpenId = function(wxOpenId) {return this.findOne({'bindings.wechat.openid': wxOpenId});
}MemberSchema.statics.findByMobieNumber = function(mobile) {return this.findOne({'bindings.mobile.number': mobile});
}

1.2. 会员路由

在会员的路由中,我们不需要实现增加会员的路由,因为这是在登录的过程中完成的。参考我们比较早的User登录的实现,我们往下将会在auth这个认证路由中实现。

同时,我们在这个阶段不会实现修改会员信息的功能。

1.2.1. 获取所有会员信息

/**路由功能: 获取所有会员列表访问权限: 平台管理员**/
router.get('/', async (req, res, next) => {try {if (req.user.type !== 'platform') {throw new ClientError.ForbiddenError();}let query = {};const members = await Member.find(query);res.json(members);} catch (e) {next(e);}
});

这里要注意的是,我们只允许平台管理用户有权利获取会员的所有信息。

在往下描述中我们可以看到,在授权中间件中,我们会将登录用户或会员的信息存储在Express请求的req.user中。我们将登录用户的类型分成两类:

  • platform: 平台类型用户。也就是我们往后要实现的通过angularjs实现的后台管理平台的操作用户。我们在之前章节中已经实现了User平台用户的管理操作。
  • member:会员。就是我们通过小程序等各个客户端进行登录的用户。

1.2.2. 获取会员详细信息

/**路由功能: 获取指定会员信息访问权限: 所有用户和会员**/
router.get('/:id', async (req, res, next) => {try {let query = {};if ((req.params.id)) {query = {_id: req.params.id};} else {res.status(400).send('Bad Request');return;}const member = await Member.findOne(query).select('+bindings');if (!member) {res.status(404).send('Not Found.');return;}res.json(member);} catch (e) {next(e);}
});

因为会员的schema中,我们将bindings的select选项设置成false,所以我们在查找的api中显式的指定将bindings的信息取回来。

1.2.3. 删除会员

/**路由功能: 删除指定会员访问权限: 平台管理员**/
router.delete('/:id', async (req, res, next) => {try {if (req.user.type !== 'platform') {throw new ClientError.ForbiddenError();}let query = {};if ((req.params.id)) {query = {_id: req.params.id};} else {res.status(400).send('Bad Request');return;}const member = await Member.findOne(query);if (!member) {log.warn('member not found');next();return;}await member.remove();res.status(204).end();} catch (e) {next(e);}
});

同样,只有平台管理员才能删除会员。

2. 会员登录


2.1. 手机号码和验证码登录

在之前的章节中,我们已经实现了手机短信验证码的功能:

客户端通过提供手机号码调用服务器端相应的sms的api,相应的短信服务商将会把验证码以指定的模板发送到指定的手机号码上。且会将该验证码保存在redis指定的时间。详情请往回查看相应的章节。

当客户端获取到服务器端发过来的验证码的时候,我们就可以通过同时提供手机号码和验证码来调用auth模块的signin接口来进行登录操作。

router.post('/signin', async (req, res, next) => {try {const {mobile, smscode, wxcode, encryptedData, iv} = req.body;// 通过手机号和验证码登录if (mobile && smscode) {// 检查验证码是否有效const redis_code = await redis.getAsync(mobile);if (redis_code !== smscode) {throw new ClientError.VerificationCodeIncorrect();}// 检查注册会员手机号是否已经存在, 不存在的话先注册let member = await Member.findByMobieNumber(mobile);log.debug('member:',member);if (!member) {// 保存新注册会员信息const data = {nickname: config.member.default_nickname,bindings: {mobile: {number: mobile}},}member = new Member(data);member.save();}// 生成会员访问凭证并返回给客户端const auth = {id: member.id,type: 'member',ttl: config.auth.ttl.member,}const accessToken = generateAccessToken();await redis.setAsync(accessToken, JSON.stringify(auth));await redis.expireAsync(accessToken, config.auth.ttl.member);res.send({"access_token": accessToken});} else if (wxcode && encryptedData && iv) {...}catch (e) {log.error('Exception:',e);next(e);}
});

通过手机号码和验证码进行登录的流程是这样的:

  • 获取客户端发送过来的手机号码和验证码
  • 根据手机号码,获取到上面提及的在发送sms验证码时存储在redis上的验证码
  • 验证客户端提供的验证码和redis存储的验证码是否一致,如果不一致的话,给客户端返回相应错误
  • 通过手机号码到mongose数据库查找对应的会员信息
  • 如果会员不存在,那么根据提供的手机号码注册一个新的会员
  • 为登录的会员或者新注册的会员生成访问凭证
  • 将访问凭证和登录会员的关键信息保存到redis中。我们往下的授权中间件会在每个需要授权访问的api请求过来时,将这些用户关键信息取出来,挂到req.user中,以便在对应的路由中进行使用。
  • 将访问凭证返回给客户端

2.2. 授权中间件挂载用户信息

紧跟着我们对之前实现的路由中间件进行修改。在对应的api需要授权的时候,我们先从redis中根据客户端提供的访问凭证,取得上面描述的在登录过程中存储在redis上的关键的用户/会员信息,并挂到req.user上面。这样的话,在对应实现该api的路由中,就能通过访问req.user来获取到当前用户的关键信息了。

const authMidware = async (req, res, next) => {try {const accessToken = req.headers.authorization;log.debug('accessToken:', accessToken);if (req.path === '/favicon.ico') {res.status(404).end();return;}// APIS need no authenticationif (req.path === '/'|| req.path === '/v1/auth/login'|| req.path === '/v1/pingpp/webhooks'|| req.path === '/v1/auth/register'|| req.path === '/v1/auth/signin'|| (req.path === '/v1/members' && req.method === 'GET')|| req.path.startsWith('/v1/pingpp/channel/')|| req.path.startsWith('/v1/uploads')) {log.debug('no auth required');next();return;}// APIS need authenticationif (accessToken) {const user = await redis.getAsync(accessToken);if (!user) {throw new ClientError.InvalidTokenError();} else {req.user = JSON.parse(user);await redis.expireAsync(accessToken, req.user.ttl);next();}} else {throw new ClientError.InvalidTokenError();}} catch (e) {log.debug('error while auth', e);next(e);}
};

这里我们整理下,客户端从登录到发起一个请求的过程是怎样的:

  • 客户端通过向服务器发起一个获取验证码的请求
  • 服务器端将验证码通过短信运营商发送给客户端,同时将手机号码和验证码作为键值存储在redis上,并设置一定的过期时间(比如10分钟),表明该时间段内该验证码都是有效的。
  • 客户端获取到验证码后,将手机号码和验证码一并发给服务器进行登录操作
  • 服务器登录完成后,将会员关键信息以访问凭证为键保存到redis中,并把访问凭证返回给客户端
  • 客户端通过该访问凭证发起一个需要授权的请求,比如获取笔记列表
  • 授权中间件根据过来的访问凭证,从redis中获取到上面的会员id和类型等关键信息,并将该关键信息挂到请求的req.user上面
  • 请求的处理路由从req.uer中获取到对应用户信息,并判断是否有足够的权限来执行该请求

2.3. 微信授权登录小程序

2.3.1 微信授权登录简述

所谓登录,就是通过客户端提供的用户的登录凭证,验证其凭证的有效性,在通过验证后将访问凭证返回给客户端,让客户端和服务端通过该凭证来进行通信。

所以登录的关键要素就是:

  • 一个可以标识用户身份的id: 比如手机号码
  • 一个可验证有效性的登录凭证: 比如短信验证码

通过手机号码和短信验证码进行登录时,我们是用手机号和验证码作为登录凭证。服务器端验证手机号对应的保存在redis缓存上的验证码和客户端传过来的验证是否一致来验证有效性,然后将访问凭证返回给客户端来作为通信凭据。

那么微信授权登录小程序的时候,我们又是用什么作为标识用户身份的id和用什么作为登录凭证呢?这里我们可以参考微信小程序登录相关的描述:
https://mp.weixin.qq.com/debug/wxadoc/dev/api/api-login.html#wxloginobject

从中我们可以看到,小程序客户端只需要调用wx.login获取一个登录凭证的code,然后发给服务器端去处理,就能完成登录的流程。

那么,为什么只是提供一个code就能完成登录流程呢? 不是说登录需要两个关键要素吗?此时在我们的脑海中,应该存在以下几个问题:

  • 这个code的有效性是可验证的吗?

  • 我们登录的另外一个用于身份标识的关键要素在哪里呢?怎么客户端没有提供出来?

事实上,解答这些问题的信息都已经隐含在这个登录凭证code里面了:

  • 服务器端会通过code到微信服务器去换取用户身份的标识,也就是微信用户在小程序上的openid。这就解答了为什么客户端不需要单独的提供一个叫做openid的会员身份凭证

  • 服务器端在通过code到微信服务器去换取用户身份的标识的时候,微信服务器会验证这个code的有效性,只有有效的code才能正确的换取到微信用户的身份信息。这就解答了我们怎么验证这个code的有效性的问题了。只是这个验证不是我们的服务器端来做的,是委托微信服务器来做的。

至于上面小程序登录时序图中的3rd_session的处理,其实就是我们此前描述的auth模块中的访问凭证access_token的工作方式。

2.3.2. 小黄书小程序微信授权登录简述

如果我们只是为了实现简单的登录并获得访问凭证的话,上面的流程就已经足够了。

但是这里还存在一个问题,就是我们需要的不仅仅是openid这个用户凭证,我们还需要获得微信头像和性别等信息以存储在数据库中。

微信开发文档并没有提供方法让我们通过openid直接获取到微信用户的头像等信息。

所以,这里会好的做法是微信小程序客户端除了提供一个登陆凭证code之外,还能提供上微信用户的基本信息的数据结构。

事实上,小程序在调用wx.login之后,如果在调用wx.getUserInfo就能获得微信用户的关键信息。

参数 类型 说明
userInfo OBJECT 用户信息对象,不包含 openid 等敏感信息
rawData String 不包括敏感信息的原始数据字符串,用于计算签名。
signature String 使用 sha1( rawData + sessionkey ) 得到字符串,用于校验用户信息,参考文档 signature。
encryptedData String 包括敏感数据在内的完整用户信息的加密数据,详细见加密数据解密算法
iv String 加密算法的初始向量,详细见加密数据解密算法

其中encryptedData中包含的信息会比较详尽,但是里面的内容是加密过的。虽说我们现在不一定需要用到这么详尽的信息,但是为了今后的可扩展性,我们还是有必要将其解密出来。

如果我们的小程序有通过开放平台(注意是开放平台)跟公众号进行绑定的话,我们还可以获得在公众号和小程序上相同的unionid。

{"openId": "OPENID","nickName": "NICKNAME","gender": GENDER,"city": "CITY","province": "PROVINCE","country": "COUNTRY","avatarUrl": "AVATARURL","unionId": "UNIONID","watermark":{"appid":"APPID","timestamp":TIMESTAMP}
}

要在服务器端解密encryptedData,我们需要提供三个关键要素:

  • appId: 小程序的appid,我们可以在配置中指定
  • iv : 加密算法的初始向量。就是上面的wx.getUserInfo调用成功时返回的其中一个参数
  • session_key: 服务器端通过code到微信服务器换取openid的时候,同时还会得到一个session_key,这个session_key就是专门用来解密微信提供的这些加密数据用的
{     session_key: 'oS6eJ9A1NiEEuDMr2Q9GsQ==',expires_in: 7200,openid: 'ovN8b0aJY2bi49DVVVMAP6sz4mV0' }

那么我们的小黄书小程序微信授权登录的流程应该如下:

  • 微信客户端通过wx.login获取到code
  • 微信客户端通过wx.getUserInfo获取到加密了openid等微信用户关键信息的encryptedData。
  • 微信客户端将code和encryptedData发送到服务器端
  • 服务器端根据code从微信服务器获取到代表该用户的openid和用于解密encryptedData的session_key。如果获取失败,代表登录失败
  • 如果解密失败,证明提供的code获取的session_key不能正确的解密出encryptedData中的openid,登录失败
  • 解密成功后,验证通过code获取到的openid时候和encryptedData中的openid一致。如果不一致,登录失败
  • 登录成功,生成访问凭证并返回给客户端

2.3.3. 获取微信用户openid和session_key

在小程序上调用wx.login接口时,微信服务器会返回一个登录凭证code。小程序将这个code发送给我们的服务器,我们就可以通过这个code去微信服务器换取到对应的openid和session_key了。

const options = {url: 'https://api.weixin.qq.com/sns/jscode2session',method: 'GET',json: true,qs: {grant_type: 'authorization_code',appid: confidential.xiaochengxu.appid,secret: confidential.xiaochengxu.secret,js_code: wxcode}};
const response = await request(options);

返回来的结果:

    response: { session_key: 'oS6eJ9A1NiEEuDMr2Q9GsQ==',expires_in: 7200,openid: 'ovN8b0aJY2bi49DVVVMAP6sz4mV0' }

2.3.4. 解密客户端提供的微信用户关键信息

如前面的描述,我们需要通过code获得的session_key才能对加密后的encryptedDataj进行解密。除了session_key之外,我们还需要提供小程序的appId和一个叫做初始验证向量iv的值,而这个值也是小程序中通过wx.getUserInfo获取到的。

具体的解密算法请查看微信官方提供的描述:
https://mp.weixin.qq.com/debug/wxadoc/dev/api/signature.html

同时微信官方也为我们提供了解密需要用到的库,在上面的连接中我们可以找到WXBizDataCrypt这个库。根据所提供的nodejs版本的解密示例,我们的解密代码将会如下:

// 根据session_key和iv解密加密会员信息const wXBizDataCrypt = new WXBizDataCrypt(confidential.xiaochengxu.appid, response.session_key)const userInfo = wXBizDataCrypt.decryptData(encryptedData , iv)

2.3.5. 验证解密是否成功及openid是否一致

解密后,我们就可以验证解密是否成功,以及解密后的数据和通过code获取的数据是否一致了。

    // 如果解密失败或者解密后的openid和通过code获取到的openid不一致,登录失败if(!userInfo.openId || userInfo.openId !== response.openid) {throw new ClientError.InvalidWxLoginError();}

2.3.6. 新会员注册

如果登录会员的openid在数据库中不存在的话,我们需要先对其进行注册。

  // 检查会员openid是否存在, 不存在的话先注册let member = await Member.findByOpenId(userInfo.openId);log.debug('member:',member);if (!member) {// 保存新注册会员信息const data = {nickname:  userInfo.nickName,avatar: userInfo.avatar,gender: userInfo.gender === 1 ? '男' : '女',bindings: {wechat: {openid: response.openid,nickname: userInfo.nickName,gender: userInfo.gender === 1 ? '男' : '女',avatar: userInfo.avatar,country: userInfo.country,city: userInfo.city}},}member = new Member(data);member.save();}

2.4. 完整的登录流程代码参考

router.post('/signin', async (req, res, next) => {try {const {mobile, smscode, wxcode, encryptedData, iv} = req.body;log.debug('mobile:',mobile,'smscode:',smscode, 'wxcode:',wxcode, 'encryptedData:',encryptedData, 'iv:',iv);// 通过手机号和验证码登录if (mobile && smscode) {// 检查验证码是否有效const redis_code = await redis.getAsync(mobile);log.debug(`smscode save in redis for mobile:${mobile} is: ${redis_code}`)if (!config.run_mode.is_debug) {if (redis_code !== smscode) {throw new ClientError.VerificationCodeIncorrect();}}// 检查注册会员手机号是否已经存在, 不存在的话先注册let member = await Member.findByMobieNumber(mobile);log.debug('member:',member);if (!member) {// 保存新注册会员信息const data = {nickname: config.member.default_nickname,bindings: {mobile: {number: mobile}},}member = new Member(data);member.save();}// 生成会员访问凭证并返回给客户端const auth = {id: member.id,type: 'member',ttl: config.auth.ttl.member,}const accessToken = generateAccessToken();await redis.setAsync(accessToken, JSON.stringify(auth));await redis.expireAsync(accessToken, config.auth.ttl.member);res.send({"access_token": accessToken});} else if (wxcode && encryptedData && iv) {// 通过wxcode获取到微信用户的openid 和 session_keyconst options = {url: 'https://api.weixin.qq.com/sns/jscode2session',method: 'GET',json: true,qs: {grant_type: 'authorization_code',appid: confidential.xiaochengxu.appid,secret: confidential.xiaochengxu.secret,js_code: wxcode}};const response = await request(options);log.debug('response:',response);if (response.errcode) {throw new ClientError.GetOpenIdError();}// 根据session_key和iv解密加密会员信息const wXBizDataCrypt = new WXBizDataCrypt(confidential.xiaochengxu.appid, response.session_key)const userInfo = wXBizDataCrypt.decryptData(encryptedData , iv)log.debug('encryptedData: ', userInfo);// 如果解密失败或者解密后的openid和通过code获取到的openid不一致,登录失败if(!userInfo.openId || userInfo.openId !== response.openid) {throw new ClientError.InvalidWxLoginError();}// 检查会员openid是否存在, 不存在的话先注册let member = await Member.findByOpenId(userInfo.openId);log.debug('member:',member);if (!member) {// 保存新注册会员信息const data = {nickname:  userInfo.nickName,avatar: userInfo.avatar,gender: userInfo.gender === 1 ? '男' : '女',bindings: {wechat: {openid: response.openid,nickname: userInfo.nickName,gender: userInfo.gender === 1 ? '男' : '女',avatar: userInfo.avatar,country: userInfo.country,city: userInfo.city}},}member = new Member(data);member.save();}// 生成会员访问凭证并返回给客户端const auth = {id: member.id,type: 'member',ttl: config.auth.ttl.member,}const accessToken = generateAccessToken();await redis.setAsync(accessToken, JSON.stringify(auth));await redis.expireAsync(accessToken, config.auth.ttl.member);res.send({"access_token": accessToken});}}catch (e) {log.error('Exception:',e);next(e);}
});

3. 结语


详细实现请查看github中的代码。

  • git clone https://github.com/zhubaitian/XiaoHuangShuServer.git
  • cd XiaoHuangShuServer/
  • git checkout CH09
  • npm install
  • gulp dev

这一系列文章其实我写了有段时间了,后来忙起来忘了发布了

[小黄书后台]会员管理及微信授权登录相关推荐

  1. [小黄书后台]文件上传到服务器

    上一篇文章我们实现了小黄书后台框架中的鉴权服务.今天我们会开始实现文件上传的服务,因为我们参考的小红书中有很多地方是需要上传图片的,比如商品的图片等. 1. Express Multer中间件 Exp ...

  2. [小黄书后台]手机短信验证码

    上一章我们实现了将图片上传到又拍云上的功能.今天我们会实现手机验证码的功能. 我们现在到处都会碰到手机验证码的验证方式,比如在注册的时候,通过输入手机号码,获取一个验证码,然后输入完成注册. 1. 螺 ...

  3. [小黄书后台]Insomnia及HelloWorld

    我们今天开始迈出小黄书项目实战的第一步.前几篇文章就主要围绕Nodejs+Express的架构的构建.当我们架构建好之后,到时我们只需要在需要的位置填写代码就好了,开发起来就能非常快速. 本篇文章作为 ...

  4. [小黄书后台]文件上传到CDN

    上一篇文章我们通过multer这个中间件将图片顺利的上传到了我们的服务器上面,且将图片的元数据存储到了Image这个mongodb的collections里面. 这一章我们看下应该如何将文件上传到cd ...

  5. [小黄书管理平台]登录服务的实现

    上一章我们重新定制了登录页面,且学习了angularjs通过模型驱动表单,更好的对表单以及表单下面的控件进行控制. 这一章我们会开始动手写代码,实现小黄书管理平台的登录功能.其中涉及以下几个点: 支持 ...

  6. [小黄书小程序]微信授权登录

    在上一篇文章中,我们在后台实现了会员管理以及会员注册登录的逻辑.其中登录方式包括: 通过手机号码和验证码登录 通过微信授权登录 在小黄书小程序上,我们支持第二种登录方式.第一种方式,今后看情况再酌情进 ...

  7. [小黄书管理平台]登录页面和模型驱动表单

    上一章我们对小黄书管理平台实现了中文国际化的支持,让我们的管理平台可以根据浏览器当前的语言来自动显示中文或者英文. 本章我们计划开始看下ng2-admin的登录页面: 如何通过Formbuilder来 ...

  8. [小黄书小程序]导航栏和标题栏界面

    我们前面几章已经将小黄书的后台基础框架给搭建好了.后台的小黄书相关的业务逻辑,会在往后根据客户端的业务需求来进行实现. 今天起我们会开始小黄书小程序的实现. 我们先看下小红书应用的主界面. 这一篇文章 ...

  9. [小黄书小程序]主页面标签栏水平滑动和下拉弹出框

    上一章我们实现了小黄书小程序的搜索栏的界面呈现.这一章我们将会参考小红书实现搜索栏下面的标签栏. 该标签栏存在的意义是,用户可以根据选择的标签来快速呈现相关的内容.该标签栏界面主要是两个功能模块. 一 ...

最新文章

  1. 《阿里巴巴Java开发规约》插件全球首发!
  2. 网络带宽由什么决定_加工中心价格分析,加工中心价格是由什么决定的呢?
  3. mediumtext_mediumtext数据类型 | 学步园
  4. 《高性能网站建设指南》学习笔记
  5. 二分查找法的实现和应用汇总
  6. 新闻系统查询思路和步骤
  7. System.Data.SQLite(SQLite ADO.NET 2.0的提供程序,已经包含Sqlite引擎)
  8. 使用开源ASR框架在Mono和.NET C#中进行语音识别
  9. 家用无线路由器的相关设置
  10. python怎么导入csv文件数据-机器学习Python实践——数据导入(CSV)
  11. ppc上的DataGrid运用 (C#)(版本1.2)
  12. 利用MO实现流场显示
  13. bjui获取当前页签或者是dialog容器
  14. 安装win10系统以及升级win10 home至enterprise版本
  15. OCR识别发票扫描仪解决方案
  16. 熊出没之奇幻空间里面的机器人图片_《熊出没之奇幻空间》里面令人触动的两个角色...
  17. 3月大修之后 Flash的安全问题继续被诟病?
  18. 手机淘宝app、xsign签名算法
  19. 数据库 1NF、2NF、3NF、BCNF
  20. 药品市场信息查询-药品数据库(全面)

热门文章

  1. JavaScript基础思维导图
  2. 真的来了:ArcGIS Pro2.5可以安装使用了-安装教程(附安装包下载)
  3. Vue3通透教程【四】Vue3组合API初体验
  4. 中兴F607Za设备TTL接线图等资料分享
  5. PHP的imagettftext方法中文乱码
  6. el-table 合计并放表格最上方
  7. 汇编与接口技术课程总结1——汇编程序debug调试
  8. Properties 工具类 和 ResourceBundle 工具类
  9. 使用UltraISO制作Windows 10启动U盘
  10. matlab有限元三角,三角形单元有限元计算程序(matlab)