【异次元壁】RE: 从零开始的小程序前后端实战

让我们从零开始,一起来开发一个壁纸分享小程序吧!本篇为前后端开发记录,前端内容会偏少,着重记录后端Node服务的开发经历。

小程序介绍

我们先来看看这个小程序是干嘛的吧,这样才知道该怎么码第一行代码~

【异次元壁】RE: 从零开始的小程序前后端实战

如图,这个小程序功能其实比较简单,主要就是两个功能,一个是图片列表展示,一个是大图查看以及下载分享。

手机浏览的同学可以试试点开链接访问小程序

电脑浏览的同学可以在微信小程序里面搜索异次元壁也可以扫下面的码看看实际效果

【异次元壁】RE: 从零开始的小程序前后端实战

前端篇

技术栈

Taro、Vue3、NutUI

因为最近都在写Vue3,所以本次实践就用了Taro+Vue3。

项目结构

项目结构严格按照笔者之前的文章进行搭建,有兴趣的同学可以查看详情

省流版如下:

【异次元壁】RE: 从零开始的小程序前后端实战

注意事项

因为前端侧的逻辑都十分的简单,所以也没有太多需要单独拎出来讲的东西,比较特殊的大概就下面会讲到的3点。

① 预留更新逻辑

小程序的更新和我们常见的H5部署更新不太一样,它是有周期性的向用户推送新版本,也就是说如果小程序的访问量大的话,很有可能会遇见落后两三个版本的用户还在继续访问。

所以我们需要给小程序留一个强制更新的逻辑,这样可以最大限度的避免因版本差异导致的业务问题。

/* app.js */
const updateManager = wx.getUpdateManager()

updateManager.onUpdateReady(function () {
  wx.showModal({
    title: '更新提示',
    content: '新版本已经准备好,是否重启应用?',
    success: function (res) {
      if (res.confirm) {
        updateManager.applyUpdate()
      }
    }
  })
})

② 预留配置项

这一点其实和我们现在常说的配置化思维是一样的,只是在小程序里保有这种思维收益可能会更大。因为小程序的正式版发布是需要经过微信官方审核的,这个审核时长可能会比较长,而且还有被驳回的风险。

所以我们在设计页面数据的时候就应该考虑,哪些东西需要做成可配置的,然后在对应的接口或者配置文件中预留相关配置,以应对后续的改动。

③ 小程序环境获取

const accountInfo = wx.getAccountInfoSync()
const env = accountInfo.miniProgram.envVersion
/**
 * env 有以下三个值
 * develop - 开发版
 * trial - 体验版
 * release - 正式版
 */

这个环境获取有什么用呢?主要还是用作测试、生产环境的接口区分,如果不在逻辑里面自动区分的话很容易会出现,提审上去的正式版还用着测试的接口。

温馨提醒:这个环境恐怕不太能够作为某些功能的审核开关,因为在提审的时候有可能走的是develop也有可能走trial环境。(在微信社区里也可以看到相关的反馈,但是一直未被修复)

后端篇

技术栈

Node、Nest、MongoDB

后端还是用了我们的老朋友Nest来搭建,数据库用的是MongoDB。

项目结构

项目的整体结构则是按照笔者之前的文章进行搭建,有兴趣的同学可以查看详情

省流版如下:

【异次元壁】RE: 从零开始的小程序前后端实战

也许有同学会疑惑,这不是做BFF的结构么,后端业务也能用?

其实应该是这样的,它本来就是能够支撑后端业务的结构,所以自然也能覆盖BFF所需的所有功能。本项目和BFF最大的区别就是接入的数据库,补全了增删改查的操作。

接下来将会挑几个重要的模块来阐释设计、实践思路。

用户模块

该模块主要用来处理小程序用户的存储以及用户数据拉取。

涉及的表主要是两个:

User – 存储用户基础信息userIdopenIduserInfo

UserIds – 用于产出自增userIndex

涉及的接口有下列几个:

小程序登录接口

接口入参:

code – 小程序调用wx.login产生的code

如果不清楚这个code是怎么拿到的,可以看看下面这篇笔者的远古文章

小程序各种姿势实现登录

接口逻辑:

① 用code获取用户的openId

public async handleUserLogin(createUserDto: CreateUserDto) {
    const { code } = createUserDto
    const [err, session]: [any, IGetSessionRes] = await this.infraService.fetch({
        url: this.configService.get<string>('wxApi.code2session'),
        method: 'GET',
        params: {
            appid: this.configService.get<string>('wx.appid'),
            secret: this.configService.get<string>('wx.secret'),
            js_code: code,
            grant_type: 'authorization_code',
        }
    })

    if (err) {
        throw new BadRequestException(err.message)
    }

    if (session.errcode) {
        throw new BadRequestException(session.errmsg)
    }

    const { openid } = session
    
    // ...
}

② 用①中获取的openId创建新用户

因为前端无法感知当前用户是否是新用户,所以我们在拿到openId之后需要先查一下用户是否已存在。如果是老用户则直接返回用户信息,反之则进入创建新用户的分支。

public async handleUserLogin(createUserDto: CreateUserDto) {
    // ...

    const { openid } = session
    let user: UserDocument = await this.getUser(openid)
    if (!user) {
        user = await this.userCreate(openid)
    }
    return {
        // ...userInfo
    }
}

初始化新用户逻辑如下,用户的唯一性依赖于微信小程序的openId,但是又不想将openId暴露出去,所以将其MD5之后生成userId,后续服务内都通过userId进行查询。

因为MongoDB里面是没有自增ID的,所以我们需要维护多一个自增表,用来记录用户的userIndex

public async userCreate(openId: string) {
    const time = Date.now()
    const userId = MD5(openId).toString()
    const userIds = await this.userIdsModel.findOneAndUpdate({ name: 'user' }, {
        $inc:{ 'index': 1 }
    }).exec()
    const user = new this.userModel({
        userId,
        openId,
        createTime: time,
        updateTime: time,
        userIndex: userIds.index,
    })
    return await user.save()
}

小程序用户信息获取

接口入参

userId – 用户唯一标识

接口逻辑:

比较经典的用户模块接口,因为系统比较简单,所以暂时只需要根据userId调用findOne方法把User表对应的数据查询出来就好了。

如果后续新增了收藏、历史或者积分等系统,这个接口需要查询的东西就会变多。

小程序用户信息更新

接口入参

userId – 用户唯一标识

updateUserInfo – 需要更新的用户信息

接口逻辑:

同上,也是经典的用户模块接口,根据userId调用findOneAndUpdate方法即可完成用户信息更新。

public async updateUserInfo(updateUserInfo: UpdateUserInfoDto, userId: string) {
    const user = await this.userModel.findOneAndUpdate({ userId }, {
        userInfo: updateUserInfo,
    }).exec()
    // ...
}

图片模块

该模块是项目核心,小程序所有功能几乎都是围绕这个模块展开的。它主要负责图片、图集的存储以及获取。

涉及的表主要是两个:

Picture – 存储图片基础信息

Combo – 存储图集基础信息

涉及的接口有下列几个:

图片添加

接口入参

pictures – 待添加的图片列表

Picture的数据结构如下

【异次元壁】RE: 从零开始的小程序前后端实战

接口逻辑:

因为每次图库更新基本都是批量新增的,所以直接做了一个批量插入的接口,同时也可以支持单个插入。

添加的接口很简单,因为对每个图片的处理在采集下来的时候就通过批量脚本先处理好了。一般来说采集回来的图片会有这三个信息:oldPicIdartistpicTags,随后通过脚本,将oldPicIdMD5后生成属于系统内部的picId,然后再将picId与静态资源域名拼接生成图片链接。

public async addPictures(pictures: CreatePictureDto[]) {
    const list: Picture[] = pictures.map((item: CreatePictureDto) => {
        const createTime = Date.now()
        return {
            ...item,
            createTime,
        }
    })
    await this.pictureModel.insertMany(list)
    return {}
}

根据picIdoldPicId获取图片详情

接口入参

picId – 图片ID

接口逻辑

因为每个图片都有两个ID,前端不同的场景可能会用不同的ID进行详情获取。原本这是分开了两个接口getDetailgetDetailByOldPicId,后面发现这是多此一举了,完全可以通过$or操作符来实现需求。

因为我们不知道前端传过来的到底是picId还是oldPicId,所以需要分别用这两个字段查一遍,只要有合适的就可以返回。

public async getDetail(picId: string): Promise<PictureDocument> {
    const picture = await this.pictureModel.findOne({
        $or: [
            { picId },
            { oldPicId: picId }
        ]
    }).exec()
    return picture
}

根据Tag获取图片列表

接口入参

tags – 标签集合

pageIndex – 页码

pageSize – 每页条目数量

接口逻辑

根据传入的标签进行列表搜索,最终返回条目列表以及列表总数。

因为有些图片可能会影响微信审核,所以就在查询条件里加了一道,将所有标签里含有EX的条目都过滤掉。在审核通过之后,把$nor操作符注释掉即可。

然后这个接口还需要支持首页图片列表的搜索,所以在tags参数为空时,将不会对picTags字段进行过滤。

最后,将查到的条目进行输出前的格式化,最终和总条目count一起返回给前端。

public async getListByTag(getPictureListByTags: GetPictureListByTags) {
    const { tags, pageIndex, pageSize } = getPictureListByTags
    const query: any = {
        '$nor': [
            { picTags: 'EX' }
        ]
    }
    if (tags) {
        query.picTags = {
            '$in': tags,
        }
    }
    const picList: PictureDocument[] = await this.pictureModel.find(query).sort({ _id: -1 }).limit(pageSize).skip((pageIndex - 1) * pageSize).exec()
    const count = await this.pictureModel.find(query).count()
    const items = this.handlePicList(picList)
    return { items, count }
}

获取图集列表

Combo数据结构如下

【异次元壁】RE: 从零开始的小程序前后端实战

接口逻辑

public async getPictureCombo() {
    const comboList: ComboDocument[] = await this.ComboModel.find().exec()
    const promises = comboList.map(async (combo) => {
        const { comboId, comboIntro, comboTitle, items } = combo
        const comboPost = (await this.pictureModel.findOne({ picId: combo.comboPost }).exec()).picUrl
        // 额外处理...
        return { comboId, comboIntro, comboTitle, comboPost, items }
    })
    return await Promise.all(promises)
}

因为comboPost实际存储的是目标图片的picId,所以查出来之后还需要去Picture表把对应的图片链接查询出来,再一同返回给前端。

在实际业务中需要对picUrl做其他处理,所以就做成了逐个查询,如果可以查出来的picUrl可以直接使用的话,可以考虑批量查询所有的封面。

(这里其实可以在录入combo的时候进行优化,把图片链接直接作为comboPost存储到document里,就可以减少查询。)

获取图集详情

接口入参

comboId – 图集ID

接口逻辑

根据前端传入的comboId,在Combo表中查询对应的图集详情,然后根据详情中的items(其实是picId的集合)到Picture表中进行批量查询,然后将图片详情格式化后返回给前端进行图集渲染。

public async getPictureComboDetail(comboId: string) {
    const comboDoc: ComboDocument = await this.ComboModel.findOne({ comboId }).exec()
    const { comboIntro, comboTitle, items } = comboDoc
    const picList = await this.pictureModel.find({
        picId: {
            $in: items
        }
    }).exec()
    return { comboId, comboIntro, comboTitle, items: this.handlePicList(picList) }
}

微信模块

因为是微信生态的应用,所以肯定会和微信的接口打交道的,这个模块负责所有对微信的交互。

涉及的表主要是一个:

Subscribe – 存储用户订阅信息

涉及的接口有下列几个:

获取AccessToken(内部)

微信官方获取AccessToken的文档

严格来说这个不是接口,是只在内部调用的方法,它主要负责生成调用微信API所需的AccessToken。因微信对getAccessToken的调用有数量限制,所以需要对生成的AccessToken进行缓存,这里用的是封装好的lru-cache工具进行时效性缓存。

private async generateAccessToken(): Promise<String> {
    const CACHE_KEY = 'WX-ACCESS-TOKEN'
    const cache = this.cacheService.get(CACHE_KEY)
    if (cache) return cache

    const [err, accessToken]: [any, IGetAccessTokenRes] = await this.infraService.fetch({
        url: this.configService.get<string>('wxApi.getAccessToken'),
        method: 'GET',
        params: {
            appid: this.configService.get<string>('wx.appid'),
            secret: this.configService.get<string>('wx.secret'),
            grant_type: 'client_credential',
        }
    })

    if (err) { }

    this.cacheService.set(CACHE_KEY, accessToken.access_token, accessToken.expires_in)

    return accessToken.access_token
}

大家可能会注意到,如果按上述逻辑运行,如果调用generateAccessToken频率较高的话可能会出现多次getAccessToken的情况。

现在小程序访问量还小,所以没有优化这一块,后续应该会用定时任务去刷新AccessToken,确保其他服务每次调用generateAccessToken都能拿到AccessToken而不是再去请求微信获取。

生成小程序链接

微信官方生成小程序链接的文档

为什么需要生成小程序链接呢?主要场景是应对微信外的小程序跳转,生成了小程序链接可以在(有微信的移动端)任何可以跳转页面的地方打开我们的小程序,让小程序的推广更加便利。

手机浏览的同学可以点这里试试

具体的生成逻辑如下,因为永久小程序链接是有数量限制的,所以笔者建议最好是在一个H5页面进行临时小程序链接的生成,这样会灵活一点。

public async generateUrlLink(generateUrlLink: GenerateUrlLinkDto) {
    const accessToken = await this.generateAccessToken()
    const [err, urlLink]: [any, IGenerateUrlLinkRes] = await this.infraService.fetch({
        url: this.configService.get<string>('wxApi.generateUrlLink'),
        method: 'POST',
        params: {
            access_token: accessToken
        },
        data: generateUrlLink
    })
    if (err) { }
    return urlLink.url_link || ''
}

模板订阅

【异次元壁】RE: 从零开始的小程序前后端实战

注意:这里主要讨论的是微信小程序的一次性模板订阅

微信的消息订阅肯定让不少同学头疼过,感觉头疼的主要原因还是因为大家只能片面的接收信息。微信的消息订阅是需要前后端通力合作的,但是在实际的开发任务中,开发同学往往只能接收到一半的内容。

前端同学很熟悉怎么触发订阅行为、订阅模板ID应该放在哪个地方;后端同学则很清楚怎么推送消息、怎么关联接收人和模板消息。其实只要把双方都知道的信息合并一起,就可以很清晰的看到整个消息推送的全貌了。

【异次元壁】RE: 从零开始的小程序前后端实战

如图,前端在触发订阅后,如果用户同意订阅,我们的后端就会接收到微信推送过来的消息,里面大概会包含用户的openId、模板id以及订阅状态。

然后,后端业务就会将这些信息按一定的数据结构落库,后续需要推送的时候就会在库里按需消耗

为什么要用消耗这个词?因为这个是一次性的订阅,用户的每次同意订阅就像是给此openId下的指定模板添加一次库存,随后每推送一次都需要消耗一次库存,如果没有库存了则意味着我们不再有权限向用户推送该模板消息。

那么第二个问题又来了,微信怎么知道他的消息要推到哪里呢?这就需要我们在小程序的后台配置我们的服务器信息,如下图。

【异次元壁】RE: 从零开始的小程序前后端实战

按文档指引配置好之后,就可以接收到微信推送过来的信息了。然而,这里可能还有一个坑~

如果我们的服务是分环境部署的话,这里就会有一个很尴尬的问题。每个小程序只能配一个服务器地址,这就导致我们的环境区分失效了。所以笔者才会提倡,当小程序规模较大时,申请多一个小程序专门用作测试,另一个小程序则是稳定的正式版,这样能避免很多类似这样的坑。

说到这,还有最后一个问题,我们不想配置服务器地址的话能实现模板推送吗?可以的,笔者的小程序就是这么实现的。

【异次元壁】RE: 从零开始的小程序前后端实战

思路很简单,既然服务器接收不到微信的推送了,那我们自己给服务器上报不就得了?因为前端是可以知晓用户订阅的具体行为的,用户是否同意某几个模板都是可以通过API回调获取,然后我们只需要将这些信息连同用户的openId一起上报到后端服务即可,最终还是走回一样的推送流程。

下面简单写一下这一块的前后端逻辑:

小程序

const handleSubscribe = () => {
  Taro.requestSubscribeMessage({
    tmplIds: [
      'xxxx-xxxxx',
    ],
    success(res) {
      const templateIds = []
      Object.keys(res).forEach((key) => {
        if (res[key] === 'accept') {
          templateIds.push(key)
        }
      })
      const userData = wx.getStorageSync('__LOGIN_INFO__')
      WxService.subscribeUpdate({
        data: {
          openId: JSON.parse(userData).openId,
          templateIds,
        }
      }).then(() => {
        Taro.showToast({
          title: '订阅成功',
          icon: 'none'
        })
      })
    }
  })
}

后端接口

public async subscribeUpdate(subscribeUpdateDto: SubscribeUpdateDto) {
    const { openId, templateIds } = subscribeUpdateDto
    const subscribeInfo: SubscribeDocument = await this.subscribeModel.findOne({ openId }).exec()
    if (!subscribeInfo) {
        const templates = {}
        templateIds.forEach((t) => {
            templates[t] = 1
        })
        const si = new this.subscribeModel({
            openId,
            templates,
        })
        await si.save()
        return {}
    }
    const inc = {}
    templateIds.forEach((t) => {
        inc[`templates.${t}`] = 1
    })
    await this.subscribeModel.findOneAndUpdate({ openId }, {
        $inc: inc
    })
    return {}
}

运维相关

目前数据库和服务是部署在同一个服务器上的,并且没有上容器。其中node服务是通过PM2来进行部署以及维护,提供给前端的接口转发则是由Nginx来完成,这里NG的配置十分简单,只需要将locationproxy_pass到指定的端口即可,剩下的都由业务应用来处理就行了。

图片管理

本来是用腾讯云的COS进行图库管理,后面发现免费流量并不够用,所以转到了七牛。

不过无论是哪个静态储存提供商,我们要做的事情都是一样的。现在的提供商基本都有提供配套的图片上传以及图片处理API,我们只需要根据文档接入即可。

然后是关于缩略图的处理,一开始是人工对一批批的图进行压缩。后期发现图床基本都有提供基础的压缩、裁剪服务,我们只需要在图片链接的后面加上制定的参数就可以压缩图片,并且不会损失太多的质量。

示例:

原图 – www.xxx.com/test.png

缩略图 – www.xxx.com/test.png?im…

参考文档:腾讯云-数据万象七牛云-图片处理

尾声

好啦,一个简单壁纸小程序的前后端就完成了,希望能对大家有所帮助。

关于这个小程序后续规划,打算更新一个排行榜功能以及标签筛选功能,做完之后也会像这样写一篇记录。

本文正在参加「金石计划」

原文链接:https://juejin.cn/post/7215496238626521144 作者:mykurisu

(0)
上一篇 2023年3月29日 上午10:26
下一篇 2023年3月29日 上午10:37

相关推荐

发表回复

登录后才能评论