全栈项目(nodejs + react)仿商城小程序
微信搜索:AI工作台 小程序,默认登录权限是游客
一朋友想要一个商品展示小程序,闲来无事,先开发一仿商城小程序来练手。
前端(tarojs + react + valtio + nutui-react-taro + ahooks + decimal)
框架介绍
- taro 开放式跨端跨框架
- valtio 状态管理 容易上手的状态管理工具
- nutui-react-taro 组件库,基于 Taro,使用 React 技术栈开发小程序应用
- ahooks 一套高质量可靠的 React Hooks 库
- decimal 一个的任意精度十进制类型JavaScript库
主函数介绍
微信的request
import Taro from '@tarojs/taro'
import { mCommon, mUser } from '@/store'
import { to } from './index'
/**
* 微信的的request
*/
function request(all) {
return to(
new Promise(function (resolve, reject) {
mCommon.onToast('loading')
if (mUser.token) {
}
Taro.request({
method: 'post',
header: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
},
timeout: 30 * 1000,
...all,
success: function (res) {
mCommon.toastOpen = false
// console.log('request', { res })
if (res.statusCode === 200) {
if (res.data.code === 0) {
resolve(res.data.data)
} else {
mCommon.onToast(`${res.data.code} ! ${res.data.message}`)
reject(res.data)
}
} else {
mCommon.onToast(`${res.statusCode} ! ${res.data.message}`)
reject(res)
}
},
fail: function (err) {
mCommon.toastOpen = false
mCommon.onToast(err.errMsg)
console.log('request', { err })
reject(err)
},
})
}),
)
}
export default request
Decimal
// 加
export function add(num1, num2) {
return new Decimal(num1).plus(num2).toString()
}
// 减
export function subtract(num1, num2) {
return new Decimal(num1).minus(num2).toString()
}
// 乘
export function multiply(num1, num2) {
return new Decimal(num1).times(num2).toString()
}
// 除
export function divide(num1, num2) {
return new Decimal(num1).dividedBy(num2).toString()
}
`
后端(midwayjs + prisma + qiniu)
框架介绍
- midwayjs Node.js 框架
- prisma 巨好用的一个TS ORM
- qiniu 静态资源存储在七牛云
主要函数介绍
prisma
生成数据库 一行命令创建数据库 更新数据库, 巨方便
// npx prisma migrate dev --name init
// 管理员
model User {
id String @id @unique @default(uuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// @map 作用是为字段 别名
openId String @unique @map("open_id")
email String @default("")
password String @default("")
code String @default("")
codeAt DateTime @default(now()) @map("code_at")
//
shop Shop[]
// @@map 数据库表别名
@@map("db_user")
}
七牛 sdk
const qiniu = require('qiniu');
const sharp = require('sharp');
export const qiniuConfig = {
accessKey: '****',
secretKey: '****',
bucket: 'byebye',
url: 'https://**.***.***', // 七牛sdk
area: 'z2', // 区域
path_test: 'mm-test/',
path_prod: 'mm-prod/',
};
// 创建各种上传凭证之前,我们需要定义好其中鉴权对象mac
const mac = new qiniu.auth.digest.Mac(
qiniuConfig.accessKey,
qiniuConfig.secretKey
);
export const doUpload = async (key, file, mimeType) => {
let buffer;
const isImage = mimeType.indexOf('image') !== -1;
if (isImage) {
// 压缩图片
const image = sharp(file);
if (mimeType === 'image/jpeg') {
image.jpeg({ quality: 30 });
} else if (mimeType === 'image/png') {
image.png({ quality: 30 });
} else if (mimeType === 'image/webp') {
image.webp({ quality: 30 });
}
buffer = await image.toBuffer();
}
// 创建上传凭证token
const options = {
scope: qiniuConfig.bucket + ':' + key,
};
const putPolicy = new qiniu.rs.PutPolicy(options);
const uploadToken = putPolicy.uploadToken(mac);
// 服务端直传
/*
* 七牛存储支持空间创建在不同的机房,
* 在使用七牛的 Node.js SDK 中的FormUploader和ResumeUploader上传文件之前,
* 必须要构建一个上传用的config对象,在该对象中,可以指定空间对应的zone以及其他的一些影响上传的参数
* */
const config = new qiniu.conf.Config();
config.zone = qiniu.zone[`Zone_${qiniuConfig.area}`]; //z0代表 华东机房
const formUploader = new qiniu.form_up.FormUploader(config);
const putExtra = new qiniu.form_up.PutExtra();
return new Promise((resolve, reject) => {
if (isImage) {
formUploader.put(
uploadToken,
key,
buffer,
putExtra,
(err, body, info) => {
if (err) {
reject(err);
}
if (info.statusCode === 200) {
resolve(body);
} else {
reject(body);
}
}
);
} else {
formUploader.putFile(
uploadToken,
key,
file,
putExtra,
(err, body, info) => {
if (err) {
reject(err);
}
if (info.statusCode === 200) {
resolve(body);
} else {
reject(body);
}
}
);
}
});
};
获取微信openid
// 获取openId
@Post('/jscode2session')
async jscode2session(@Body() req: jscodeDto) {
console.log({ req });
const APP_ID = '****'; // 微信小程序APP_ID
const APP_SECRET = '****'; //微信小程序APP_SECRET
const APP_URL = 'https://api.weixin.qq.com/sns/jscode2session';
const url = `${APP_URL}?appid=${APP_ID}&secret=${APP_SECRET}&js_code=${req.code}&grant_type=authorization_code`;
const [err, res] = await request({ url });
if (err) {
return resErr({ message: 'code失效', err });
}
return resWin({ message: 'success', data: res });
}
token验证
const jwt = require('jsonwebtoken');
const fs = require('fs');
const path = require('path');
const { to } = require('./tool');
export const getJwt = payload => {
const privateKeyPath = path.resolve(__dirname, '../../private.key');
const privateKey = fs.readFileSync(privateKeyPath);
// const token = jwt.sign(payload, privateKey, {
// algorithm: 'RS256',
// expiresIn: '2m', // 5m:5分钟 1h:1小时 1天:1 * 24 * 60 * 60
// });
return to(
new Promise((resolve, reject) => {
jwt.sign(
payload,
privateKey,
{
algorithm: 'RS256',
expiresIn: '5m', // 5m:5分钟 1h:1小时 1天:1 * 24 * 60 * 60
},
(err, decoded) => {
if (err) {
reject(err);
} else {
resolve(decoded);
}
}
);
})
);
};
export const checkJwt = token => {
const publicPath = path.resolve(__dirname, '../../public.key');
const publicKey = fs.readFileSync(publicPath);
return to(
new Promise((resolve, reject) => {
jwt.verify(
token,
publicKey,
{ algorithms: ['RS256'] },
(err, decoded) => {
if (err) {
reject(err);
} else {
resolve(decoded);
}
}
);
})
);
};
jwt 中间件
import { Middleware } from '@midwayjs/core';
import { Context, NextFunction } from '@midwayjs/koa';
import { checkJwt, getJwt } from '../utils/token';
import { resErr } from '../utils/tool';
@Middleware()
export class JwtMiddleware {
public static getName(): string {
return 'jwt';
}
resolve() {
return async (ctx: Context, next: NextFunction) => {
// 判断下有没有校验信息
if (!ctx.headers['authorization']) {
return resErr({
code: 401,
message: '缺少authorization',
env: process.env.NODE_ENV,
});
}
// 从 header 上获取校验信息
const parts = ctx.get('authorization').trim().split(' ');
if (parts.length !== 2) {
return resErr({ code: 401, message: 'authorization格式不规范' });
}
const [scheme, token] = parts;
if (/^Bearer$/i.test(scheme)) {
const [err, res] = await checkJwt(token);
if (err) {
return resErr({ code: 401, message: 'token解析失败' });
}
// iat:生效时间 exp:失效时间
const timestamp = Math.floor(Date.now() / 1000);
const timeDifference = res.exp - timestamp;
if (timeDifference < 4 * 60) {
// 马上过期 更新token
const [errJwt, resJwt] = await getJwt({
phone: res.phone,
id: res.id,
});
if (errJwt) {
return resErr({ code: 401, message: 'token更新失败' });
}
ctx.set('Authorization', resJwt);
}
await next();
}
};
}
// 配置忽略鉴权的路由地址
public match(ctx: Context): boolean {
// const whitelist = ['/mm/user', '/mm/base', '/mm/product', '/mm/shop'];
const whitelist = ['/mm'];
const whitelistIndex = whitelist.map(x => ctx.path.indexOf(x));
return !whitelistIndex.includes(0);
}
}
上线
服务端 ci/cd 上线,使用Drone
服务端使用的是drone,小巧灵活,我喜欢。Jenkins太重了, 我那小服务器坑不住, 有机会单开一篇文章详细介绍我如何使用drone。
.drone.yml
kind: pipeline
name: default
pipeline:
build-dev:
image: docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- docker-compose -f docker-compose.dev.yaml down
- docker-compose -f docker-compose.dev.yaml build
- docker-compose -f docker-compose.dev.yaml up -d
when:
event: tag
branch: '*'
ref: refs/tags/dev.*
privileged: true
build-prod:
image: docker
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- docker-compose -f docker-compose.prod.yaml down
- docker-compose -f docker-compose.prod.yaml build
- docker-compose -f docker-compose.prod.yaml up -d
when:
event: tag
branch: '*'
ref: refs/tags/prod.*
privileged: true
小程序上线
审核流程比较麻烦, 因为账户是个人,小程序不能是商城, 所以好多小程序里的文案进行了修改,方便审核上线。