实践:给女朋友个性化定制应用-体重记录(三) 
此系列的目的是帮助前端新人,熟悉现代前端工程化开发方式与相关技术的使用,普及一些通识内容
前景回顾 
- 实践:给女朋友个性化定制应用-体重记录(一),主要阐述了应用前端工程的搭建与部分页面开发
- 实践:给女朋友个性化定制应用-体重记录(二),主要阐述了体重记录页面的开发,后端接口设计,数据库设计
本文详细介绍一下后端开发和部署,前后端联调的内容
本文涉及内容 
- 接口开发
- 接口鉴权
- 前后端联调
- 后端部署
一期最终效果展示 
- 提供了测试账号一键登录
- 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/releaseAxios配置 
- 通过刚刚设置的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的玩具模板框架,后面出文详细介绍此部分的详细设计与实现
用户部分接口 
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服务上线部署

