介绍

最近接触到MERN技术栈,想做一个全栈的项目练练手。正好之前在上网课的时候有提到如何使用第三方服务如stripe搭建一个支付系统,把这个支付系统拓展一下就可以变为一个电商网站(或者二手交易网站)了。整个项目大概花了一星期不到的时间(周一到周六完成开发,周日写这篇文章),一开始以为会是一个简单的CRUD应用,然而做的时候才体会到该项目的复杂性,有很多模块之间相互影响,还有很多安全性、跨域问题需要考虑。感觉现在前端的功能还是非常强大的,基本上很多业务逻辑都可以在前端完成,然后通过RESTFul api从后台获取所需数据即可,开发时间相对以往则大大缩短。该项目虽然简单,但也实现了基本的电商网站功能比如用户登录、卖家创建和管理商品,购物车功能,商品支付与结算,订单管理等功能,整个项目代码量大概在3000多行。

Demo

Source Code - Front-end

Source Code - Back-end

项目结构

该项目主要分为用户认证、商品、购物车、支付和订单管理五个模块,采用前后端分离的模式,前端使用axios通过api获取后端数据。前后端的文件结构如下

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# Client
├── public
│ └── index.html
└── src
├── actions
│ ├── index.js
│ └── types.js
├── components
│ ├── App.js
│ ├── Authentication
│ │ ├── Login.js
│ │ └── OAuthPanel.js
│ ├── Cart
│ │ ├── CartHooks.js
│ │ ├── CartMenu.js
│ │ ├── CartMenuItem.js
│ │ └── Checkout.js
│ ├── Checkout
│ │ ├── CardSection.js
│ │ ├── Checkout.js
│ │ ├── CheckoutForm.css
│ │ ├── CheckoutForm.js
│ │ ├── InjectedCheckoutForm.js
│ │ ├── OrderDetail
│ │ │ ├── AddressForm.js
│ │ │ ├── Index.js
│ │ │ └── OrderList.js
│ │ └── Success.js
│ ├── Dashboard
│ │ ├── BuyList.js
│ │ ├── ComfirmShipment.js
│ │ ├── FieldFileInput.js
│ │ ├── IBuy.js
│ │ ├── OrderDetail.js
│ │ ├── SellList.js
│ │ ├── hooks.js
│ │ └── iSell.js
│ ├── Header
│ │ ├── Header.js
│ │ └── HeaderMenu.js
│ ├── Landing
│ │ ├── Filter.js
│ │ ├── Landing.js
│ │ └── Searchbar.js
│ ├── Message.js
│ └── Products
│ ├── ProductCreate.js
│ ├── ProductDetail.js
│ ├── ProductEdit.js
│ ├── ProductForm.js
│ ├── ProductHooks.js
│ └── ProductList.js
├── history.js
├── hooks
│ ├── useProduct.js
│ ├── useProducts.js
│ └── useUser.js
├── index.js
├── reducers
│ ├── authReducer.js
│ ├── cartReducer.js
│ ├── index.js
│ └── messageReducer.js
├── resources
│ └── categories.js
└── store.js

# Server
├── index.js
├── middlewares
│ └── requireLogin.js
├── models
│ ├── Cart.js
│ ├── Order.js
│ ├── Product.js
│ ├── Transaction.js
│ └── User.js
├── routers
│ ├── auth.js
│ ├── cart.js
│ ├── order.js
│ ├── payment.js
│ └── product.js
└── services
└── createPayment.js

模块设计

用户数据和认证模块

该模块主要负责将新用户注册到数据库,并在前端通过api获取数据时验证用户身份。这一部分为了减轻项目复杂度,我就没有实现用户名和密码注册登录,而是通过Passport.js使用Google和Facebook OAuth进行验证登录。

  1. 后端设置一个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);
    }
  1. 设置登录和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
    );
  2. 用户在前端登录时跳转到/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!" });
    });
  3. 前端在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
    4
    const 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. 商品数据结构

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const 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列表
    });
  2. 商品列表获取

    这里我设置了一个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
    10
    router.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);
    });
  3. 显示商品信息

    使用一个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
    17
    const 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
    9
    router.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);
    }
    });
  4. 创建和修改商品

    创建和修改可以在前端共享一个ProductForm表单组件,通过使用redux-form管理该表单。

    对于修改表单,则可设置reduxForm的enableReinitialize为true,通过使用useProduct Hook获取product数据后作为initialValues props传入到ProductForm组件中即可。共享该表单组件可以减轻许多任务量。

    另外在后台需要设置两个middlewares requireLoginproductUpdateCheck 来检验用户是否登录,用户是否有权限修改该商品,以及用户所更新的信息是否合法等:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const 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();
    };
  5. 删除商品

    该部分比较简单,只需要从数据库中找到商品,验证商品是否由该用户创建,并删除即可。通过使用一个productOwnershipCheck中间件验证该商品的发布者:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    const 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的部分。

  1. 该模块的数据通过redux存储在store中以供全局访问,包括两个action creator: setCartItemfetchCartItems

    • 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;
    };
  2. 后端将购物车中的数据存储到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,
    },
    ],
    });
  3. cart在redux中的初始值为null,当值为null时,购物车需要使用fetchCartItems异步调用api获取上次用户访问所保存的购物车中的商品数据并保存在store中

  4. 创建一个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
    8
    const 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]);
  5. 使用一个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
    22
    useEffect(() => {
    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);
    }
    };
  6. 后端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
    28
    router.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发来的订单确认信息,并将订单标记为“已支付”状态

  1. 确认和创建订单:这里我们使用一个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
    13
    useEffect(() => {
    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]);
  2. 信用卡支付页面:当用户点击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
    30
    const 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,
    });
  3. 使用stripe自带的CardElement组件创建信用卡表单,然后使用之前获取的client secret提交支付请求:

    1
    2
    3
    4
    5
    const payload = await stripe.confirmCardPayment(clientSecret, {
    payment_method: {
    card: elements.getElement(CardElement),
    },
    });
  4. 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
    24
    router.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
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
router.get("/sell", requireLogin, async (req, res) => {
try {
let orders = await Order.find({
owner: req.user.id,
status: { $ne: "noPaid" },
});
res.status(200).send(orders);
} catch (error) {
res.status(500).send(error);
}
});

router.get("/buy", requireLogin, async (req, res) => {
try {
let orders = await Order.find({
buyer: req.user.id,
status: { $ne: "noPaid" },
});
res.status(200).send(orders);
} catch (error) {
res.status(500).send(error);
}
});

router.post("/comfirmShipment", requireLogin, async (req, res) => {
try {
let { orderId, trackCode, shipmentProvider } = req.body;
let order = await Order.findById(orderId).populate("owner").exec();
if (!order || order.owner.id !== req.user.id) {
res.status(400).send({ msg: "Unauthorized access!" });
return;
}
if (trackCode) order.trackCode = trackCode;
if (shipmentProvider) order.shipmentProvider = shipmentProvider;
order.status = "shipped";
await order.save();
res.status(200).send({ msg: "success!" });
} catch (error) {
res.status(500).send(error);
}
});

router.post("/comfirmReceived", requireLogin, async (req, res) => {
try {
let { orderId } = req.body;
let order = await Order.findById(orderId).populate("buyer").exec();
if (!order || order.buyer.id !== req.user.id) {
res.status(400).send({ msg: "Unauthorized access!" });
return;
}
order.status = "completed";
await order.save();
res.status(200).send({ msg: "success!" });
} catch (error) {
res.status(500).send(error);
}
});

网站部署和跨域问题

最终网站的成品命名为iShop。该项目前后端是分离的,前端使用React并部署在Vercel上(shop.wei.ai),后端用Express搭建并部署在Google App Engine上(shop-api.wei.ai)。其实一开始后端本来是部署在Heroku上的,后面在开发的过程中遇到一些跨域问题,即safari浏览器仅接受来自相同域名的跨域api调用(参考之前的blog),heroku的免费版又不支持自建域名和https。刚好之前GCP新用户注册还剩小半年免费期,就先部署在GCP了。当然缺点也是蛮明显的,就是国内用户访问很慢。以后可能会考虑将应用迁移到位于境内的服务器上。

(另外打个小广告,我最近注册了wei.ai域名,欢迎大家有空访问并提出意见)

最终成品

首页和购物车
首页和购物车
商品展示页
商品展示页
结算页面
结算页面
卖家管理页面
卖家管理页面

未完待续。。。

(以下内容有时间或需求的话会继续更新)

  1. 图片上传模块(可能于近期写一个图床应用,但需要一个大一点空间的服务器,穷o(╥﹏╥)o)
  2. 评论、点赞和收藏模块