介绍
最近接触到MERN技术栈,想做一个全栈的项目练练手。正好之前在上网课的时候有提到如何使用第三方服务如stripe搭建一个支付系统,把这个支付系统拓展一下就可以变为一个电商网站(或者二手交易网站)了。整个项目大概花了一星期不到的时间(周一到周六完成开发,周日写这篇文章),一开始以为会是一个简单的CRUD应用,然而做的时候才体会到该项目的复杂性,有很多模块之间相互影响,还有很多安全性、跨域问题需要考虑。感觉现在前端的功能还是非常强大的,基本上很多业务逻辑都可以在前端完成,然后通过RESTFul api从后台获取所需数据即可,开发时间相对以往则大大缩短。该项目虽然简单,但也实现了基本的电商网站功能比如用户登录、卖家创建和管理商品,购物车功能,商品支付与结算,订单管理等功能,整个项目代码量大概在3000多行。
项目结构
该项目主要分为用户认证、商品、购物车、支付和订单管理五个模块,采用前后端分离的模式,前端使用axios通过api获取后端数据。前后端的文件结构如下
1 | Client |
模块设计
用户数据和认证模块
该模块主要负责将新用户注册到数据库,并在前端通过api获取数据时验证用户身份。这一部分为了减轻项目复杂度,我就没有实现用户名和密码注册登录,而是通过Passport.js使用Google和Facebook OAuth进行验证登录。
后端设置一个PassportConfig负责配置passport.js,注册用户,序列化和反序列化用户信息等操作
(需要先从google developer console获取client-secret和clientID,并在console中配置app url和callback url)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// server/passportConfig.js
// 1. 创建Strategy
const gStrategy = new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID, // provided by GCP console
clientSecret: process.env.GOOGLE_CLIENT_SECRET,// provided by GCP console
callbackURL: process.env.GOOGLE_CALLBACK_URL, // need to be configured in GCP console
},
async (accessToken, refreshToken, profile, done) => { // 用户授权后,google返回用户数据(在profile中)
try { // 根据profile中的googleUserId在用户数据库中查找用户
let user = await User.findOne({ googleUserId: profile.id });
// ... 判断用户数据库中是否存在user,如果不存在则注册一个新user
} catch (error) done(error);
}
);
// 2. 序列化和反序列化用户(将用户数据存储在cookie / 从cookie中提取用户数据)
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
let user = await User.findById(id);
done(null, user);
} catch (error) done(error);
});(补充:大陆用户无法直接使用Google服务,因此需要设置代理(如果server部署在大陆服务器上))
1
2
3
4
5
6
7
8/ only required in dev environment since I deployed the production environment in Google App Engine
if (process.env.NODE_ENV === "development") {
var HttpsProxyAgent = require("https-proxy-agent");
const agent = new HttpsProxyAgent(
process.env.HTTP_PROXY || "YOUR_PROXY_ADDRESS"
);
gStrategy._oauth2.setAgent(agent);
}
设置登录和callback路由
1
2
3
4
5
6// server/routers/auth.js
router.get("/auth/google", passport.authenticate("google", { scope: ["profile"] }));
router.get( "/auth/google/callback",
passport.authenticate("google"), // 认证并设置cookie
(req, res) => res.redirect(`${process.env.CLIENT_BASE_URL}`); // 重定向至前端URL
);用户在前端登录时跳转到
/auth/google
,会重定向至google验证页面。用户完成授权后,会从验证页面重定向回/auth/google/callback
并携带授权码code,然后passport使用code获取用户profile等信息,完成用户认证或注册,设置cookie并重定向回前端相应页面。1
2
3
4
5
6
7
8
9
10// 登录route
router.get("/auth/user", (req, res) => {
if (req.isAuthenticated()) res.status(200).send(req.user._doc);
else res.status(401).send({ msg: "User is not login!" });
});
// 退出登录route
router.get("/auth/logout", (req, res) => {
req.logOut();
res.status(200).send({ msg: "Log out success!" });
});前端在Landing Page,以及每个需要登录后才可访问的页面组件中调用
GET /auth/user
API检查用户登录情况,并将用户数据以全局的方式存储在redux中,以供后续组件访问Action Creator
1
2
3
4
5
6
7// client/src/actions/index.js
export const fetchAuthStatus = () => async (dispatch) => {
try {
const user = await axios.get(`${process.env.REACT_APP_API_BASE_URL}/auth/user`);
dispatch({ type: FETCH_AUTH_STATUS, user: user.data });
} catch (error) dispatch({ type: FETCH_AUTH_STATUS, user: false });
};Reducer
1
2
3
4const authReducer = (state = [true, null], action) => {
if (action.type === FETCH_AUTH_STATUS) return [false, action.user];
return state;
};
OAuth登录具体原理可以在我之前的blog (link)中查看。
商品模块
这部分主要涉及商品的CRUD操作,相对比较基础,不过也涉及到一些redux-form表单等技术的使用,同时还实现了fuzzy-search模糊检索的功能
商品数据结构
1
2
3
4
5
6
7
8
9const productSchema = new mongoose.Schema({
owner: { type: mongoose.Schema.Types.ObjectId, ref: "User" }, // 创建者
name: String, // 商品名
intro: String, // 商品介绍
price: Number, // 商品单价
quantity: Number, // 库存数量
category: String, // 商品分类
pics: [String], // 商品图片URL列表
});商品列表获取
这里我设置了一个query参数,以便前端获取商品列表时筛选所需数据。e.g. 设置
query={category: "sport"}
可以指定获取category为sport的数据,query={term: "iphone 12"}
可以执行fuzzy search等因为前端在多处可能会用到商品列表,所以我写了一个通用化的ProductList组件(篇幅太长就不在这放代码,路径为
/client/src/components/Products/ProductList.js
link)具体而言,该组件从上层组件中接收一个query props,并从redux中获得user登录数据,以及两个action creator
popMessage
&setCartItem
分别负责弹出提示和添加购物车(后续会补充)。该组件使用一个products hook通过调用异步api获取数据:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// client/src/components/hooks/useProducts.js
const useProducts = (query) => {
const [products, setProducts] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchProducts = async () => {
try {
const response = await axios.get(`${process.env.REACT_APP_API_BASE_URL}/products`,
{params: query,}
);
setProducts(response.data);
setLoading(false);
} catch (error) throw error;
};
fetchProducts();
}, [query]);
return [loading, products];
};后端route
1
2
3
4
5
6
7
8
9
10router.get("/products", async (req, res) => {
try {
let products = {};
// fuzzy search 这里需要安装mongoose_fuzzy_searching包并设置对应的Product Schema
if (req.query.term === "") products = await Product.find();
else if (req.query.term) products = await Product.fuzzySearch(req.query.term);
else products = await Product.find(req.query);
res.status(200).send(products);
} catch (error) res.status(500).send(error);
});显示商品信息
使用一个product hook异步调用
GET /product/:id
API获取具体的商品数据,并使用一个ProductDetail组件显示商品数据注意这里ProductDetail组件同时通过redux接收user信息,判断如果该商品由该登录用户所创建,则显示修改商品的按钮,反之则显示添加购物车按钮
另外还使用了
react-material-ui-carousel
库用于滚动播放产品图片useProduct Hook
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17const useProduct = (productId) => {
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchProduct = async () => {
try {
const response = await axios.get(
`${process.env.REACT_APP_API_BASE_URL}/products/${productId}`
);
setProduct(response.data);
} catch (error) console.log(error);
setLoading(false);
};
fetchProduct();
}, [productId]);
return [loading, product];
};GET Product route
1
2
3
4
5
6
7
8
9router.get("/products/:id", async (req, res) => {
try {
let product = await Product.findById(req.params.id);
if (!product) res.status(404).send({ msg: "Product not found" });
else res.status(200).send(product);
} catch (error) {
res.status(500).send(error);
}
});创建和修改商品
创建和修改可以在前端共享一个ProductForm表单组件,通过使用redux-form管理该表单。
对于修改表单,则可设置reduxForm的enableReinitialize为true,通过使用useProduct Hook获取product数据后作为initialValues props传入到ProductForm组件中即可。共享该表单组件可以减轻许多任务量。
另外在后台需要设置两个middlewares
requireLogin
和productUpdateCheck
来检验用户是否登录,用户是否有权限修改该商品,以及用户所更新的信息是否合法等:1
2
3
4
5
6
7
8
9
10
11
12
13
14const requireLogin = (req, res, next) => {
if (req.isAuthenticated()) next();
else res.status(400).send({ msg: "Unauthorize access!" });
};
const productUpdateCheck = (req, res, next) => {
let product = req.body;
if (!product.name || !product.intro || !product.price ||!product.quantity ||!product.pics ||!product.category) {
res.status(400).send({ msg: "Invalid product submit!" });
}
product.owner = req.user; // add product owner
req.product = product;
next();
};删除商品
该部分比较简单,只需要从数据库中找到商品,验证商品是否由该用户创建,并删除即可。通过使用一个productOwnershipCheck中间件验证该商品的发布者:
1
2
3
4
5
6
7
8
9
10
11
12const productOwnershipCheck = async (req, res, next) => {
try {
let productId = req.params.id;
let product = await Product.findById(productId).populate("owner").exec();
if (req.user.id !== product.owner.id)
req.status(400).send({ msg: "Unauthorized access!" });
else next();
} catch (error) {
console.log(error);
res.status(500).send(error);
}
};
购物车模块
这个模块设计到与其他多个模块之间(如用户数据模块、商品模块和后面支付模块)的数据交互,即这些模块都可以影响到购物车模块中的数据,比如用户可以在商品模块中将商品添加到购物车,支付模块需要提取购物车中的商品数据进行结算,同时购物车需要将其中的商品数据定时上传到用户数据模块,以供用户下次登录时使用。个人感觉该模块是这个商城应用中最复杂也是最容易出bug的部分。
该模块的数据通过redux存储在store中以供全局访问,包括两个action creator:
setCartItem
和fetchCartItems
:setCartItem
接收一个product对象和amount数值作为参数,用于设置购物车中某样商品的数量fetchCartItems
从后端获取购物车数据(见第三点)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37// action/index.js
export const setCartItem = (product, amount) => {
let payload = {};
payload.product = product;
payload.amount = amount;
return { type: SET_CART, payload: payload };
};
export const fetchCartItems = () => async (dispatch) => {
try {
let res = await axios.get(`${process.env.REACT_APP_API_BASE_URL}/cart`);
dispatch({ type: FETCH_CART_ITEMS, items: res.data });
} catch (error) {
console.log(error);
}
};
// reducers/cartReducer
const cartReducer = (state = null, action) => {
if (action.type === FETCH_CART_ITEMS) return action.items;
if (action.type === SET_CART) {
let updateItem = action.payload;
let newState = [...state];
let hasItem = false;
for (let item of newState) {
if (item.product._id === updateItem.product._id) {
hasItem = true;
item.amount = updateItem.amount;
}
}
if (!hasItem) newState.push(updateItem);
newState = newState.filter((item) => item.amount > 0);
return newState;
}
return state;
};后端将购物车中的数据存储到cart数据库中,该数据结构包含购物车所有者userId,以及一个items数组,用于存储每个在购物车中的商品productId以及数量amount:
1
2
3
4
5
6
7
8
9
10// server/models/Cart.js
const cartSchema = new mongoose.Schema({
userId: String,
items: [
{
productId: String,
amount: Number,
},
],
});cart在redux中的初始值为null,当值为null时,购物车需要使用
fetchCartItems
异步调用api获取上次用户访问所保存的购物车中的商品数据并保存在store中创建一个CartMenu组件用于展示cart中的商品信息。对每个购物车中的商品,设置一组可以调节商品数量的按钮:
(注意该组件在后面结算过程中还会用到,因此需要通用化的设计)
1
2
3
4
5
6
7
8
9
10<button onClick={(e) => {e.preventDefault(); removeItem(); }} > Delete </Button>
<button onClick={(e) => {e.preventDefault(); addItem();}} > Add </button>
<input
type="number"
value={item.amount}
// 这里限制了商品数量不能设置为小于或等于0
onChange={(e) => {if (Number(e.target.value) > 0) setAmount(Number(e.target.value));}}
onClick={(e) => e.preventDefault()}
/>
<button onClick={(e) => {e.preventDefault(); reduceItem();}} > Decrease </button>当用户点击加减或删除按钮,或者直接输入数字时,通过调用对应的listener或useEffect函数,使用
setCartItem
action creator对在redux中的cart数据进行更新:1
2
3
4
5
6
7
8const addItem = () => setAmount(Number(amount) + 1);
const reduceItem = () => { if (Number(amount) - 1 > 0) setAmount(Number(amount) - 1);};
const removeItem = () => setAmount(0);
const dispatch = useDispatch();
useEffect(() => {
const func = () => dispatch(setCartItem(item.product, amount));
func();
}, [amount]);使用一个useEffect函数监听cart的变化并将cart中数据更新到后端数据库中:
(这里使用一个timer防止抖动,当用户停止修改购物车一段时间后才进行更新,避免频繁的api调用)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22useEffect(() => {
if (cart === null) return;
const update = (cartItems) => updateCart(cartItems);
const timerID = setTimeout(() => {
const cartItems = cart.map((item) => {
return {
productId: item.product._id,
amount: item.amount,
};
});
update(cartItems);
}, 5000);
return () => {clearTimeout(timerID);};
}, [cart]);
export const updateCart = async (cart) => {
try {
await axios.post(`${process.env.REACT_APP_API_BASE_URL}/cart?_method=PUT`, cart);
} catch (error) {
console.log(error);
}
};后端route获取或更新cart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28router.get("/cart", requireLogin, async (req, res) => {
try {
let cart = await Cart.findOne({ userId: req.user.id });
if (!cart) cart = await Cart.create({ userId: req.user.id, items: [] }); // 当购物车不存在时创建购物车
cart.items = await checkItems(cart.items);
await cart.save();
let cartItemsWithProduct = await getCartItemsWithProduct(cart.items); // 获取购物车中的商品数据
res.status(200).send(cartItemsWithProduct);
} catch (error) {
res.status(500).send(error);
}
});
router.put("/cart", requireLogin, async (req, res) => {
try {
const items = await checkItems(req.body);
let cart = { userId: req.user.id, items: items };
// get cart
await Cart.findOneAndUpdate({ userId: req.user.id }, cart, {
upsert: true,
useFindAndModify: false,
});
res.status(200).send(cart);
} catch (error) {
res.status(500).send(error);
}
});这里使用了两个函数,由于篇幅太长不放代码在此,有兴趣可以参阅源码。其中,getCartItemsWithProduct负责获取cart中商品的详细数据(因为存储在cart中的仅为productId)并整理成数组发送,checkItems负责检查添加到cart中的商品数据是否和商品数据库中的数据一致,防止在传送途中被恶意修改
支付模块
该模块主要使用stripe.js实现支付功能,需要比较强的安全性。整个check out的过程分为两步:用户点击check out按钮后,从redux中提取购物车的数据,并让用户填写送货地址表单。之后后端需要验证该交易是否合法(验证商品信息和金额等是否被篡改),然后使用stripe创建一个新的支付订单,用户在前端通过stripe完成支付后,后端服务器通过webhook收到stripe发来的订单确认信息,并将订单标记为“已支付”状态
确认和创建订单:这里我们使用一个OrderDetail组件,其中包含AddressForm和OrderList两个组件
- AddressForm组件使用一个redux-form来创建address表单
- OrderList组件包含之前购物车模块中的CartMenu组件,用于显示购物车中的商品数据
另外我们需要在前端对address表单和cart中的数据进行验证:
创建一个disableButton的state,默认值为false
使用一个useEffect函数监听address和cart,只有当两者都合法时才将disableButton设为ture,从而允许点击Next按钮
1
2
3
4
5
6
7
8
9
10
11
12
13useEffect(() => {
if (cart === null) return;
if (cart.length === 0) history.push("/");
if (!address || !address.firstName || !address.lastName || !address.address1 ||
!address.city || !address.state || !address.zip || !address.country) return;
setDisableButton(false);
for (let item of cart) {
if (item.amount > item.product.quantity) {
setDisableButton(true);
break;
}
}
}, [address, cart]);信用卡支付页面:当用户点击next按钮后,将cart和address数据打包成一个transaction对象post到后端
/payment
后端验证商品信息后在数据库中创建订单,并使用stripe创建一个PaymentIntent对象(stripe用于管理每笔支付的对象),并将其中的client_secret返回给前端:
1
2
3
4
5
6
7
8
9
10
11
12
13
14// client checkoutForm.js
useEffect(() => {
// Create PaymentIntent as soon as the page loads
const fetchClientSecret = async () => {
const response = await axios.post(
`${process.env.REACT_APP_API_BASE_URL}/payment`,
transaction
);
const { client_secret, totalPrice } = response.data;
setClientSecret(client_secret);
setTotalPrice(totalPrice);
};
fetchClientSecret();
}, [transaction]);1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27// server payment.js
router.post("/payment", chekTransaction, async (req, res) => {
try {
const transaction = req.transaction;
// split transactions to orders and add to database
let createdOrders = [];
for (let order of transaction.orders) {
let product = await Product.findById(order.product.productId);
let createdOrder = await Order.create({
buyer: req.user,
owner: product.owner,
address: transaction.address,
product: order.product,
amount: order.amount,
status: "noPaid",
});
createdOrders.push(createdOrder);
}
// add transactions to database
let createdTransaction = await Transaction.create({buyer: req.user, orders: createdOrders,});
const intent = await createPayment(transaction.totalPrice, "usd", createdTransaction.id);
res.status(200).json({
client_secret: intent.client_secret,
totalPrice: transaction.totalPrice,
});
} catch (error) res.status(500).send(error);
});由于每笔transaction可能由多个买家发布的商品组成,所以这里将每笔transaction再基于单个商品分为每个order。transaction和order的数据结构如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30const transactionSchema = new mongoose.Schema({
buyer: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
orders: [{ type: mongoose.Schema.Types.ObjectId, ref: "Order" }],
});
const orderSchema = new mongoose.Schema({
buyer: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
owner: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
address: {
firstName: String,
lastName: String,
address1: String,
address2: String,
city: String,
state: String,
country: String,
zip: String,
},
product: {
productId: String,
name: String,
price: String,
pic: String,
},
amount: Number,
status: String,
date: { type: Date, default: Date.now },
trackCode: String,
shipmentProvider: String,
});使用stripe自带的CardElement组件创建信用卡表单,然后使用之前获取的client secret提交支付请求:
1
2
3
4
5const payload = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: elements.getElement(CardElement),
},
});stripe收到支付钱款后,会向一个我们预先定义的webhook发送支付状态等信息,然后我们可以以此设置transaction的status (e.g. nopaid / waiting / paid等)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24router.post("/webhook", async (req, res) => {
try {
let event = req.body;
// Handle the event
if (event.type === "payment_intent.succeeded") { //仅处理支付成功的情况
const paymentIntent = event.data.object;
let transactionId = paymentIntent.metadata.transactionId;
let transaction = await Transaction.findById(transactionId)
.populate("orders")
.exec();
console.log(transaction);
for (let order of transaction.orders) {
order.status = "paid";
await order.save();
let product = await Product.findById(order.product.productId);
if (product) {
product.quantity -= order.amount;
await product.save();
}
}
}
res.json({ received: true }); // Return a 200 response to acknowledge receipt of the event
} catch (error) console.log(error);
});
(附注:这个模块包含很多stripe的模板代码,具体可参阅源码和stripe api文档:link)
订单管理模块
该模块分为iSell卖家模块和iBuy买家模块,分别维护一个订单order列表。另外,卖家可以在order status为paid时设置已发货信息(如运单号和快递商),买家可于收到货后确认收货,该order的状态变为completed
后端route代码:
1 | router.get("/sell", requireLogin, async (req, res) => { |
网站部署和跨域问题
最终网站的成品命名为iShop。该项目前后端是分离的,前端使用React并部署在Vercel上(shop.wei.ai),后端用Express搭建并部署在Google App Engine上(shop-api.wei.ai)。其实一开始后端本来是部署在Heroku上的,后面在开发的过程中遇到一些跨域问题,即safari浏览器仅接受来自相同域名的跨域api调用(参考之前的blog),heroku的免费版又不支持自建域名和https。刚好之前GCP新用户注册还剩小半年免费期,就先部署在GCP了。当然缺点也是蛮明显的,就是国内用户访问很慢。以后可能会考虑将应用迁移到位于境内的服务器上。
(另外打个小广告,我最近注册了wei.ai域名,欢迎大家有空访问并提出意见)
最终成品
未完待续。。。
(以下内容有时间或需求的话会继续更新)
- 图片上传模块(可能于近期写一个图床应用,但需要一个大一点空间的服务器,穷o(╥﹏╥)o)
- 评论、点赞和收藏模块