实践:给女朋友个性化定制应用-体重记录(三)

本文涉及的产品
云数据库 MongoDB,独享型 2核8GB
推荐场景:
构建全方位客户视图
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 此系列的目的是帮助前端新人,熟悉现代前端工程化开发方式与相关技术的使用,普及一些通识内容

前景回顾




本文详细介绍一下后端开发和部署,前后端联调的内容


本文涉及内容


  • 接口开发
  • 接口鉴权
  • 前后端联调
  • 后端部署


一期最终效果展示


体验地址


网络异常,图片无法展示
|


  • 提供了测试账号一键登录
  • bug: 短信登录线上还有点小问题,发不出验证码


前端联调配置


Vite


前端构建工具使用的Vite

vite.config.ts中配置proxy,在开发环境时,根据指定的接口路径前缀,将请求转发到本地的后端服务

并且使用proxy能解决前端跨域的问题


import { defineConfig } from 'vite'
// https://vitejs.dev/config/
export default defineConfig({
  server: {
    proxy: {
      '/api/': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (p) => p.replace(/^\/api/, ''),
      },
    },
  },
})


环境变量


主要配置axios请求的baseUrl路径

开发环境访问接口,统一添加 /api 前缀,通过proxy转发到本地开发环境.env


VITE_APP_AXIOS_BASE_URL=/api


由于前端应用生产环境使用serverless-静态资源站点部署,不提供请求转发功能

所以在生产环境直接请求线上(serverless)的后端服务


为此通过后端开启CORS(跨域资源共享)来解决请求跨域的问题.env.production


VITE_APP_AXIOS_BASE_URL=https://service-pfwgr4kl-1256505457.cd.apigw.tencentcs.com/release


Axios配置


  • 通过刚刚设置的Vite环境变量,动态指定请求的Base路径
  • 使用token鉴权,登录成功后,将返回的token存入的Localstorage
  • 每次请求通过axios请求拦截器自动附带


import axios from 'axios'
const instance = axios.create({
  // 通过刚刚设置的Vite环境变量,动态指定请求的Base路径
  baseURL: import.meta.env.VITE_APP_AXIOS_BASE_URL,
})
/**
 * 请求拦截
 */
instance.interceptors.request.use((config) => {
  const { method, params } = config
  // 附带鉴权的token
  const headers: any = {
    token: localStorage.getItem('token'),
  }
  // 不缓存get请求
  if (method === 'get') {
    headers['Cache-Control'] = 'no-cache'
  }
  // delete请求参数放入body中
  if (method === 'delete') {
    headers['Content-type'] = 'application/json;'
    Object.assign(config, {
      data: params,
      params: {},
    })
  }
  return {
    ...config,
    headers,
  }
})


在axios响应拦截器中加入了简单的鉴权逻辑:


  • 响应code(业务定义)为401时,自定跳转到应用首页
  • 其它非0的code使用Promise.reject处理,业务调用方在catch回调中处理非正常的业务逻辑


/**
 * 响应拦截
 */
instance.interceptors.response.use((v) => {
  if (v.data?.code === 401) {
    localStorage.removeItem('token')
    // 未登录
    window.location.href = '/'
    return v.data
  }
  if (v.status === 200) {
    if (v.data.code !== 0) {
      return Promise.reject(v.data)
    }
    return v.data
  }
})
export default instance


后端联调配置


跨域配置


在构造函数拦截器中配置:


  • 使用allowOrigins配置允许访问的域名
  • 使用Array.includes在请求的时候,判断来源域名是否被允许
  • 允许访问的域名添加到Access-Control-Allow-Origin请求头中
  • 判断请求方法如果是options,默认其为预检请求,就直接返回


import { Middleware } from '@/lib/server/types'
// 允许跨域访问的源
const allowOrigins = ['https://lover.sugarat.top', 'http://lover.sugarat.top']
const interceptor: Middleware = (req, res) => {
    const { method } = req
    if (allowOrigins.includes(req.headers.origin)) {
        // 允许跨域
        res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
    }
    //跨域允许的header类型
    res.setHeader('Access-Control-Allow-Headers', '*')
    // 允许跨域携带cookie
    res.setHeader('Access-Control-Allow-Credentials', 'true')
    // 允许的方法
    res.setHeader('Access-Control-Allow-Methods', 'PUT, GET, POST, DELETE, OPTIONS')
    // 设置响应头
    res.setHeader('Content-Type', 'application/json;charset=utf-8')
    // 对预检请求放行
    if (method === 'OPTIONS') {
        res.statusCode = 204
        res.end()
    }
}
export default interceptor


环境变量


将一些敏感的数据库密码或第三方服务的身份验证凭据放入到环境变量中

本地用:.env.local


# 服务相关
PORT=3000
# 腾讯云相关
secretId=****
secretKey=****
envId=time-lover-1g02fg37bf3148fa
# 短信模板
TENCENT_MESSAGE_TemplateID=****
TENCENT_MESSAGE_SmsSdkAppid=****
# redis相关配置
REDIS_DB_HOST=****
REDIS_DB_PORT=****
REDIS_DB_PASSWORD=****


线上prod环境:.env.production.local


同时将*.local添加进.gitignore中,防止秘钥不小心泄露到GitHub上


接口开发


营养不高,也是体力活:



这里简单贴一下User部分的接口开发,为接口添加了尽可能详尽的注释帮助理解

后端部分,使用的是自己diy的玩具模板框架,后面出文详细介绍此部分的详细设计与实现


用户部分接口


src/routes/modules/user.ts


import { GlobalError, UserError } from '@/constants/errorMsg'
import { expiredRedisKey, getRedisVal, setRedisValue } from '@/db/redisDb'
import { inserUser, queryUserList } from '@/db/userDb'
import Router from '@/lib/Router'
import { randomNumStr } from '@/utils/randUtil'
import { rMobilePhone, rVerCode } from '@/utils/regExp'
import { getUniqueKey } from '@/utils/stringUtil'
import { sendMessage } from '@/utils/tencent'
import tokenUtil from '@/utils/tokenUtil'
// 设置此部分路由的公共前缀
const router = new Router('user')
// 用户登录接口
router.post('login', async (req, res) => {
    const { phone, code } = req.body
    // 测试账号数据,直接放行测试账号
    if (phone === '13245678910' && code === '1234') {
        // 通过手机号查询用户信息
        const [user] = await queryUserList({
            phone
        })
        // 直接调用createToken根据用户信息生成token(身份凭证),30天有效(自动存入redis中)
        const token = await tokenUtil.createToken(user, 60 * 60 * 24 * 30)
        res.success({
            token
        })
        return
    }
    // 参数格式不正确
    if (!rMobilePhone.test(phone) || !rVerCode.test(code)) {
        res.failWithError(GlobalError.paramsError)
        return
    }
    const v = await getRedisVal(`code-${phone}`)
    if (code !== v) {
        res.failWithError(UserError.errorCode)
        return
    }
    let [user] = await queryUserList({
        phone
    })
    // 不存在就插入
    if (!user) {
        user = {
            userId: getUniqueKey(),
            phone,
            joinTime: new Date()
        }
        await inserUser(user)
    }
    const token = await tokenUtil.createToken(user, 60 * 60 * 24 * 30)
    // 过期验证码
    expiredRedisKey(`code-${phone}`)
    res.success({
        token
    })
})
// 获取登录验证码
router.get('code', (req, res) => {
    const { phone } = req.query
    // 参数格式不正确
    if (!rMobilePhone.test(phone)) {
        res.failWithError(GlobalError.paramsError)
        return
    }
    // 随机生成一个4位长的数字
    const code = randomNumStr(4)
    if (process.env.NODE_ENV !== 'development') {
        // 调用封装的腾讯云SDK方法发送验证码
        sendMessage(phone, code, 2)
    }
    // 存入redis中,120s有效时间
    setRedisValue(`code-${phone}`, code, 120)
    console.log(code)
    res.success()
})
export default router


汇总各模块路由


// types
import { Route } from '@/lib/server/types'
// router
import user from './modules/user'
import family from './modules/family'
import record from './modules/record'
// 这里注册路由
const routers = [user, family, record]
export default routers.reduce((pre: Route[], router) => {
    return pre.concat(router.getRoutes())
}, [])


注册路由&启动服务


// polyfill
import 'core-js/es/array'
console.time('server-start')
// 从.env加载环境变量
import loadEnv from './utils/loadEnv'
loadEnv()
// 路径映射
import loadModuleAlias from './utils/moduleAlias'
loadModuleAlias()
// 配置文件
import { serverConfig } from './config'
// diy module 自建模块
import FW from './lib/server'
// routes
import routes from './routes'
// interceptor
import { serverInterceptor, routeInterceptor } from './middleware'
const app = new FW(serverInterceptor, {
    beforeRunRoute: routeInterceptor
})
// 注册路由
app.addRoutes(routes)
app.listen(serverConfig.port, serverConfig.hostname, () => {
    console.log('-----', new Date().toLocaleString(), '-----')
    if (process.env.NODE_ENV === 'development') {
        // 写入测试用逻辑
    }
    console.timeEnd('server-start')
    console.log('server start success', `http://${serverConfig.hostname}:${serverConfig.port}`)
})
module.exports = app


接口鉴权


在路由拦截器中判断请求携带的token是否有效,无效则直接响应无权限状态码

通过路由配置的options参数来判断路由是否需要鉴权


import { GlobalError } from '@/constants/errorMsg'
import { Middleware } from '@/lib/server/types'
import { getUserInfo } from '@/utils/tokenUtil'
const interceptor: Middleware = async (req, res) => {
    const { options } = req.route
    console.log(`路由拦截:${req.method} - ${req.url}`)
    if (options && options.needLogin) {
        const user = await getUserInfo(req)
        if(!user){
            res.failWithError(GlobalError.powerError)
        }
    }
}
export default interceptor


路由上的option参数在router.xxx的第三个参数的位置,如下示例所示


router.post('add', async (req, res) => {
    const { name } = req.body
    const { userId } = await getUserInfo(req)
    const familyId = getUniqueKey()
    await insertFamily({
        name,
        userId,
        familyId
    })
    res.success({
        familyId
    })
    // 这个路由的options配置
},{
    needLogin:true
})


工具方法


介绍一些开发时用到的工具方法


loadEnv


封装dotenv库,封装为loadEnv方法

自动按顺序依次读取项目根目录的.env.env.local.env.[mode].local.env.[mode]中的环境变量文件


// 读取配置的环境变量
import dotenv from 'dotenv'
function load(parseEnvObj) {
  const { parsed } = parseEnvObj
  if (parsed && parsed instanceof Object) {
    Object.getOwnPropertyNames(parsed).forEach((k) => {
      process.env[k] = parsed[k]
    })
  }
}
export default function loadEnv() {
  const baseDir = `${process.cwd()}/`
  // .env
  dotenv.config()
  // .env.local
  load(dotenv.config({ path: `${baseDir}.env.local` }))
  // .env.[mode].local
  load(dotenv.config({ path: `${baseDir}.env.${process.env.NODE_ENV}.local` }))
  // .env.[mode]
  load(dotenv.config({ path: `${baseDir}.env.${process.env.NODE_ENV}` }))
}


loadModuleAlias


添加module-alias路径映射库,映射项目中使用的@开头或其它自定义的路径


// 编译后的绝对路径映射插件
// 下面这行从package.json读取配置
// import 'module-alias/register'
import moduleAlias from 'module-alias'
export default function loadModuleAlias() {
  moduleAlias.addAliases({
    '@': `${__dirname}/../`,
  })
}


createToken


根据用户的userId,phone,当前时间拼接成一个字符串

调用encryption方法生成这个字符串的md5 hash摘要作为最终的用户登录凭证

将凭证作为key,用户信息序列化后的值作为value,存入redis中


function createToken(user: User, timeout = 60 * 60 * 24) {
    const { phone, userId } = user
    const token = encryption([phone, userId, Date.now()].join())
    await setRedisValue(token, JSON.stringify(user), timeout)
    return token
}


encryption


利用 crypto库的提供的方法,计算指定字符串的md5 hash摘要值,并以base64编码返回摘要结果


import crypto from 'crypto'
/**
 * 加密字符串(md5+base64)
 * @param str 待加密的字符串
 */
export function encryption(str: string): string {
    return crypto.createHash('md5').update(str).digest('base64')
}


getUniqueKey


利用 MongoDB 提供的ObjectId对象生成一个唯一的标识


关于ObjectId的介绍可以查看文章源码学习:探究MongoDB - ObjectId最新的生成原理


import { ObjectId } from 'mongodb'
export function getUniqueKey() {
    return new ObjectId().toHexString()
}


后端部署


使用腾讯云Serverless服务部署后端的Node应用,详细教程移步:Serverless实践-Node服务上线部署


资料汇总



相关实践学习
【文生图】一键部署Stable Diffusion基于函数计算
本实验教你如何在函数计算FC上从零开始部署Stable Diffusion来进行AI绘画创作,开启AIGC盲盒。函数计算提供一定的免费额度供用户使用。本实验答疑钉钉群:29290019867
建立 Serverless 思维
本课程包括: Serverless 应用引擎的概念, 为开发者带来的实际价值, 以及让您了解常见的 Serverless 架构模式
相关文章
|
4月前
|
搜索推荐 C语言
青年歌手大赛:实时评分统计与分析程序设计
青年歌手大赛评分系统:C语言实现平均分计算(剔除最高与最低分) 在青年歌手大赛中,为了确保评分的公平性和准确性,本程序采用C语言设计了一套评分统计方案。该方案的核心功能是在收集10位评委对一位歌手的评分后,自动剔除一个最高分和一个最低分,然后计算剩余8个有效评分的平均值。
|
4月前
|
存储 C语言 索引
【实战编程】学生信息管理系统:一键实现数据插入、智能排序、精准查询与成绩统计(附完整源码,即学即用!)
结构体数组是C语言中一种复合数据类型,它结合了结构体的灵活性和数组的有序集合特性,允许你定义一组具有相同结构的数据项。结构体定义了一组不同数据类型的变量集合,而结构体数组则是这种结构的连续内存块,每个元素都是该结构类型的实例。这种方式特别适合管理具有相似属性的对象集合,如学生信息、员工记录等。
|
5月前
1077 互评成绩计算 (20 分)
1077 互评成绩计算 (20 分)
|
6月前
|
机器学习/深度学习 算法 数据可视化
数据报告分享|WEKA贝叶斯网络挖掘学校在校人数影响因素数据分类模型
数据报告分享|WEKA贝叶斯网络挖掘学校在校人数影响因素数据分类模型
|
6月前
|
C语言
跳水运动员预测比赛结果排名次问题详解(逻辑类型题1)
跳水运动员预测比赛结果排名次问题详解(逻辑类型题1)
62 0
|
12月前
游戏指标专业术语
游戏指标专业术语
94 0
|
算法
英雄留步,这些数据指标你知道吗?
英雄留步,这些数据指标你知道吗?
|
数据可视化 数据处理 UED
40000+条考研信息数据可视化(学校、专业分数分布)
40000+条考研信息数据可视化(学校、专业分数分布)
|
机器学习/深度学习 搜索推荐
《蘑菇街广告的排序:从历史数据学习到个性化强化学习》电子版地址
蘑菇街广告的排序:从历史数据学习到个性化强化学习
68 0
《蘑菇街广告的排序:从历史数据学习到个性化强化学习》电子版地址
|
机器学习/深度学习 数据采集 人工智能
『航班乘客满意度』场景数据分析建模与业务归因解释 ⛵
本文结合航空出行的场景,使用机器学习建模,详细分析了航班乘客满意度的影响因素:机上Wi-Fi服务、在线登机、机上娱乐质量、餐饮、座椅舒适度、机舱清洁度和腿部空间等。
447 0
『航班乘客满意度』场景数据分析建模与业务归因解释 ⛵