React-全栈项目-三-

48 阅读38分钟

React 全栈项目(三)

原文:zh.annas-archive.org/md5/05F04F9004AE49378ED0525C32CB85EB

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:扩展市场以支持订单和付款

处理顾客下订单时的付款,并允许卖家管理这些订单是电子商务应用的关键方面。在本章中,我们将通过引入以下功能来扩展上一章中构建的在线市场:

  • 购物车

  • 使用 Stripe 进行付款处理

  • 订单管理

具有购物车、付款和订单的 MERN 市场

在第六章中开发的 MERN 市场应用程序,通过在线市场锻炼新的 MERN 技能 将扩展到包括购物车功能、Stripe 集成以处理信用卡付款,以及基本的订单管理流程。以下的实现保持简单,以便作为开发这些功能更复杂版本的起点。

以下的组件树图显示了构成 MERN 市场前端的所有自定义组件。本章讨论的功能修改了一些现有的组件,如ProfileMyShopsProductsSuggestions,还添加了新的组件,如AddToCartMyOrdersCartShopOrders

完整的 MERN 市场应用程序的代码可在 GitHub 上找到github.com/shamahoque/…。您可以在阅读本章其余部分的代码解释时,克隆此代码并运行应用程序。要使 Stripe 付款的代码工作,您需要创建自己的 Stripe 账户,并在config/config.js文件中更新您的测试值,包括 Stripe API 密钥、秘密密钥和 Stripe Connect 客户端 ID。

购物车

访问 MERN 市场的访客可以通过点击每个产品上的“加入购物车”按钮将他们想要购买的产品添加到购物车中。菜单中的购物车图标将指示已添加到购物车中的产品数量,当用户继续浏览市场时。他们还可以更新购物车内容,并通过打开购物车视图开始结账。但是,要完成结账并下订单,用户将需要登录。

购物车主要是一个前端功能,因此购物车详情将在客户端本地存储,直到用户在结账时下订单。为了实现购物车功能,我们将在client/cart/cart-helper.js中设置辅助方法,以帮助使用相关的 React 组件操纵购物车详情。

添加到购物车

client/Cart/AddToCart.js中的AddToCart组件从父组件中获取product对象和 CSS 样式对象作为 props。例如,在 MERN Marketplace 中,它被添加到产品视图中,如下所示:

<AddToCart cartStyle={classes.addCart} item={this.state.product}/>

AddToCart组件本身根据传递的项目是否有库存显示购物车图标按钮:

例如,如果项目数量大于0,则显示AddCartIcon,否则呈现DisabledCartIcon

mern-marketplace/client/cart/AddToCart.js

{this.props.item.quantity >= 0 ? 
    <IconButton color="accent" dense="dense" onClick={this.addToCart}>
      <AddCartIcon className={this.props.cartStyle || 
     classes.iconButton}/>
    </IconButton> : 
    <IconButton disabled={true} color="accent" dense="dense"
      <DisabledCartIcon className={this.props.cartStyle || 
     classes.disabledIconButton}/>
    </IconButton>}

当点击AddCartIcon按钮时,将调用addToCart方法。

mern-marketplace/client/cart/AddToCart.js

addToCart = () => {
    cart.addItem(this.props.item, () => {
      this.setState({redirect:true})
    })
}

cart-helper.js中定义的addItem辅助方法,以product项目和更新状态的callback函数作为参数,然后将更新后的购物车详情存储在localStorage中并执行传递的回调。

mern-marketplace/client/cart/cart-helper.js

addItem(item, cb) {
    let cart = []
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        cart = JSON.parse(localStorage.getItem('cart'))
      }
      cart.push({
        product: item,
        quantity: 1,
        shop: item.shop._id
      })
      localStorage.setItem('cart', JSON.stringify(cart))
      cb()
    }
}

存储在localStorage中的购物车数据包含一个购物车项目对象数组,每个对象包含产品详情,添加到购物车的产品数量(默认为1),以及产品所属商店的 ID。

菜单上的购物车图标

在菜单中,我们将添加一个链接到购物车视图,并添加一个徽章,显示存储在localStorage中的购物车数组的长度,以便直观地通知用户当前购物车中有多少商品:

购物车的链接将类似于菜单中的其他链接,唯一的区别是 Material-UI 的Badge组件显示购物车长度。

mern-marketplace/client/core/Menu.js

<Link to="/cart">
    <Button color={isActive(history, "/cart")}>
       Cart
       <Badge color="accent" badgeContent={cart.itemTotal()} >
           <CartIcon />
       </Badge>
    </Button>
</Link>

itemTotal辅助方法在cart-helper.js中返回购物车长度,它读取存储在localStorage中的购物车数组并返回数组的长度。

mern-marketplace/client/cart/cart-helper.js

itemTotal() {
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        return JSON.parse(localStorage.getItem('cart')).length
      }
    }
    return 0
}

购物车视图

购物车视图将包含购物车项目和结账详情,但最初只会显示购物车详情,直到用户准备结账。

mern-marketplace/client/cart/Cart.js

<Grid container spacing={24}>
      <Grid item xs={6} sm={6}>
            <CartItems checkout={this.state.checkout}
 setCheckout={this.setCheckout}/>
      </Grid>
 {this.state.checkout && 
      <Grid item xs={6} sm={6}>
        <Checkout/>
      </Grid>}
</Grid>

CartItems组件被传递了一个checkout布尔值,以及一个用于更新此结账值的状态更新方法,以便基于用户交互来呈现Checkout组件和选项。

mern-marketplace/client/cart/Cart.js

setCheckout = val =>{
    this.setState({checkout: val})
}

Cart组件将在/cart路由处访问,因此我们需要在MainRouter组件中添加一个Route,如下所示。

mern-marketplace/client/MainRouter.js

<Route path="/cart" component={Cart}/>

CartItems 组件

CartItems组件将允许用户查看和更新其购物车中当前的物品。如果用户已登录,还将为他们提供开始结账流程的选项:

如果购物车中包含物品,CartItems组件将遍历物品并呈现购物车中的产品。如果没有添加物品,则购物车视图只显示一条消息,说明购物车是空的。

mern-marketplace/client/cart/CartItems.js

{this.state.cartItems.length > 0 ? <span>
      {this.state.cartItems.map((item, i) => {
          ...          
            … Product details
              … Edit quantity
              … Remove product option
          ...
        })
      }
     … Show total price and Checkout options … 
    </span> : 
    <Typography type="subheading" component="h3" color="primary">
        No items added to your cart.    
    </Typography>
}

每个产品项目显示产品的详细信息和可编辑的数量文本字段,以及删除项目选项。最后,它显示购物车中物品的总价和开始结账的选项。

检索购物车详细信息

cart-helper.js中的getCart辅助方法从localStorage中检索并返回购物车详细信息。

mern-marketplace/client/cart/cart-helper.js

getCart() {
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        return JSON.parse(localStorage.getItem('cart'))
      }
    }
    return []
}

CartItems组件中,我们将使用componentDidMount中的getCart辅助方法检索购物车项目并将其设置为状态。

mern-marketplace/client/cart/CartItems.js

componentDidMount = () => {
    this.setState({cartItems: cart.getCart()})
}

然后使用map函数迭代从localStorage中检索的cartItems数组,以呈现每个项目的详细信息。

mern-marketplace/client/cart/CartItems.js

<span key={i}>
  <Card>
    <CardMedia image={'/api/product/image/'+item.product._id}
         title={item.product.name}/>
         <CardContent>
                <Link to={'/product/'+item.product._id}>
                    <Typography type="title" component="h3" 
                    color="primary">
                      {item.product.name}</Typography>
                </Link>
                <Typography type="subheading" component="h3" 
               color="primary">
                      $ {item.product.price}
                </Typography>
                <span>${item.product.price * item.quantity}</span>
                <span>Shop: {item.product.shop.name}</span>
         </CardContent>
         <div>
          … Editable quantity …
          … Remove item option ...
         </div>
 </Card>
  <Divider/>
</span> 

修改数量

为每个购物车项目呈现的可编辑数量TextField允许用户更新他们购买的每种产品的数量,并设置最小允许值为1

mern-marketplace/client/cart/CartItems.js

Quantity: <TextField
          value={item.quantity}
          onChange={this.handleChange(i)}
          type="number"
          inputProps={{ min:1 }}
          InputLabelProps={{
            shrink: true,
          }}
        />

当用户更新此值时,将调用handleChange方法来强制执行最小值验证,更新状态中的cartItems,并使用辅助方法更新localStorage中的购物车。

mern-marketplace/client/cart/CartItems.js

handleChange = index => event => {
    let cartItems = this.state.cartItems 
    if(event.target.value == 0){
      cartItems[index].quantity = 1 
    }else{
      cartItems[index].quantity = event.target.value 
    }
    this.setState({cartItems: cartItems}) 
    cart.updateCart(index, event.target.value) 
  } 

updateCart辅助方法接受要在购物车数组中更新的产品的索引和新的数量值作为参数,并更新localStorage中存储的详细信息。

mern-marketplace/client/cart/cart-helper.js

updateCart(itemIndex, quantity) {
    let cart = []
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        cart = JSON.parse(localStorage.getItem('cart'))
      }
      cart[itemIndex].quantity = quantity
      localStorage.setItem('cart', JSON.stringify(cart))
    }
}

删除项目

在购物车中为每个物品呈现的删除选项是一个按钮,当点击时,它将物品的数组索引传递给removeItem方法,以便从数组中删除它。

mern-marketplace/client/cart/CartItems.js

<Button color="primary" onClick={this.removeItem(i)}>x Remove</Button>

removeItem点击处理程序方法使用removeItem辅助方法从localStorage中删除购物车中的物品,然后更新状态中的cartItems。该方法还检查购物车是否已清空,因此可以使用从Cart组件传递的setCheckout函数来隐藏结账。

mern-marketplace/client/cart/CartItems.js

removeItem = index => event =>{
    let cartItems = cart.removeItem(index)
    if(cartItems.length == 0){
      this.props.setCheckout(false)
    }
    this.setState({cartItems: cartItems})
}

cart-helper.js中的removeItem辅助方法获取要从数组中删除的产品的索引,然后将其切出,并在返回更新后的cart数组之前更新localStorage

mern-marketplace/client/cart/cart-helper.js

removeItem(itemIndex) {
    let cart = []
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        cart = JSON.parse(localStorage.getItem('cart'))
      }
      cart.splice(itemIndex, 1)
      localStorage.setItem('cart', JSON.stringify(cart))
    }
    return cart
}

显示总价

CartItems组件的底部,我们将显示购物车中物品的总价。

mern-marketplace/client/cart/CartItems.js

<span className={classes.total}>Total: ${this.getTotal()}</span>

getTotal方法将计算总价,考虑到cartItems数组中每个物品的单价和数量。

mern-marketplace/client/cart/CartItems.js

getTotal(){
    return this.state.cartItems.reduce( function(a, b){
        return a + (b.quantity*b.product.price)
    }, 0)
}

结账选项

用户将看到执行结账的选项,这取决于他们是否已登录以及结账是否已经打开。

mern-marketplace/client/cart/CartItems.js

{!this.props.checkout && (auth.isAuthenticated() ? 
    <Button onClick={this.openCheckout}>
        Checkout
    </Button> : 
    <Link to="/signin">
        <Button>Sign in to checkout</Button>
    </Link>)
}

当单击结账按钮时,openCheckout方法将使用作为属性传递的setCheckout方法在Cart组件中将结账值设置为true

openCheckout = () => {
    this.props.setCheckout(true)
}

一旦在购物车视图中将结账值设置为trueCheckout组件将被呈现,允许用户输入结账详情并下订单。

使用条纹进行支付

支付处理需要跨结账、订单创建和订单管理流程的实现。它还涉及对买家和卖家用户数据的更新。在我们深入讨论结账和订单功能的实现之前,我们将简要讨论使用条纹的支付处理选项和考虑事项,以及它在 MERN Marketplace 中的集成方式。

条纹

条纹提供了一套必要的工具,可以在任何 Web 应用程序中集成支付。这些工具可以根据应用程序的特定类型和正在实施的支付用例以不同的方式选择和使用。

在 MERN Marketplace 设置的情况下,应用程序本身将在 Stripe 上拥有一个平台,并且希望卖家在平台上连接 Stripe 账户,以便应用程序可以代表卖家对在结账时输入其信用卡详细信息的用户进行收费。在 MERN Marketplace 中,用户可以从不同商店添加产品到其购物车,因此他们的卡上的费用只会由应用程序为特定订购的产品创建,当卖家处理时。此外,卖家将完全控制从其 Stripe 仪表板上代表他们创建的费用。我们将演示如何使用 Stripe 提供的工具来使此付款设置工作。

Stripe 为每个工具提供了完整的文档和指南,并公开了在 Stripe 上设置的账户和平台的测试数据。为了在 MERN Marketplace 中实现付款,我们将使用测试密钥,并让您扩展实现以进行实时付款。

每个卖家的 Stripe 连接账户

为了代表卖家创建费用,应用程序将允许作为卖家的用户将其 Stripe 账户连接到其 MERN Marketplace 用户账户。

更新用户模型

在成功连接用户的 Stripe 账户后,我们将使用以下字段更新用户模型以存储 Stripe OAuth 凭据。

mern-marketplace/server/models/user.model.js

stripe_seller: {}

stripe_seller字段将存储卖家的 Stripe 账户凭据,并且在需要通过 Stripe 处理他们从商店出售的产品的收费时将使用此字段。

连接 Stripe 的按钮

在卖家的用户资料页面上,如果用户尚未连接其 Stripe 账户,我们将显示一个按钮,该按钮将带用户前往 Stripe 进行身份验证并连接其 Stripe 账户:

如果用户已成功连接其 Stripe 账户,我们将显示一个禁用的 STRIPE CONNECTED 按钮:

Profile组件中添加的代码将首先检查用户是否是卖家,然后再渲染任何STRIPE CONNECTED按钮。然后,第二个检查将确认给定用户的stripe_seller字段中是否已经存在 Stripe 凭据。如果用户已经存在 Stripe 凭据,则显示禁用的STRIPE CONNECTED按钮,否则显示一个连接到 Stripe 的 OAuth 链接的链接。

mern-marketplace/client/user/Profile.js

{this.state.user.seller &&
   (this.state.user.stripe_seller ?
       (<Button variant="raised" disabled>
            Stripe connected
        </Button>) :
       (<a href={"https://connect.stripe.com/oauth/authorize?response_type=code&client_id="+config.stripe_connect_test_client_id+"&scope=read_write"}}>
           <img src={stripeButton}/>
        </a>)
)}

OAuth 链接获取平台的客户端 ID,我们将在config变量中设置,并将其他选项值作为查询参数。此链接将用户带到 Stripe,并允许用户连接现有的 Stripe 账户或创建新账户。然后一旦 Stripe 的认证过程完成,它会使用在 Stripe 仪表板上设置的重定向 URL 返回到我们的应用程序的平台连接设置。Stripe 将认证代码或错误消息作为查询参数附加到重定向 URL 上。

MERN Marketplace 重定向 URI 设置为/seller/stripe/connect,将呈现StripeConnect组件。

mern-marketplace/client/MainRouter.js

<Route path="/seller/stripe/connect" component={StripeConnect}/>

StripeConnect组件

StripeConnect组件将基本上完成与 Stripe 的剩余认证过程步骤,并根据 Stripe 连接是否成功呈现相关消息:

StripeConnect组件加载时,在componentDidMount中,我们将首先解析附加到来自 Stripe 重定向的 URL 的查询参数。对于解析,我们使用了之前用于产品搜索的相同query-string npm 模块。然后,如果 URL 的query参数包含认证代码,我们将在服务器上进行必要的 API 调用,以完成来自 Stripe 的 OAuth。

mern-marketplace/client/user/StripeConnect.js

  componentDidMount = () => {
    const parsed = queryString.parse(this.props.location.search)
    if(parsed.error){
      this.setState({error: true})
    }
    if(parsed.code){
      this.setState({connecting: true, error: false})
      const jwt = auth.isAuthenticated()
      stripeUpdate({
        userId: jwt.user._id
      }, {
        t: jwt.token
      }, parsed.code).then((data) => {
        if (data.error) {
          this.setState({error: true, connected: false,
          connecting:false})
        } else {
          this.setState({connected: true, connecting: false, 
          error:false})
        }
      })
    }
 }

stripeUpdate fetch 方法在api-user.js中定义,并将从 Stripe 检索的认证代码传递给我们将在服务器上设置的 API'/api/stripe_auth/:userId'

mern-marketplace/client/user/api-user.js

const stripeUpdate = (params, credentials, auth_code) => {
  return fetch('/api/stripe_auth/'+params.userId, {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify({stripe: auth_code})
  }).then((response)=> {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

Stripe 认证更新 API

一旦 Stripe 账户连接成功,为了完成 OAuth 过程,我们需要使用检索到的授权码从我们的服务器向 Stripe OAuth 发出 POST API 调用,并检索凭据以存储在卖家的用户账户中以处理收费。Stripe 授权更新 API 在/api/stripe_auth/:userId接收请求,并启动向 Stripe 发出 POST API 调用以检索凭据。

此 Stripe 授权更新 API 的路由将在用户路由中声明如下。

mern-marketplace/server/routes/user.routes.js

router.route('/api/stripe_auth/:userId')
   .put(authCtrl.requireSignin, authCtrl.hasAuthorization,   
    userCtrl.stripe_auth, userCtrl.update)

对这个路由的请求使用stripe_auth控制器方法从 Stripe 检索凭据,并将其传递给现有的用户更新方法以存储在数据库中。

为了从我们的服务器向 Stripe API 发出 POST 请求,我们将使用request npm 模块:

npm install request --save

用户控制器中的stripe_auth控制器方法将如下所示。

mern-marketplace/server/controllers/user.controller.js

const stripe_auth = (req, res, next) => {
  request({
    url: "https://connect.stripe.com/oauth/token",
    method: "POST",
    json: true,
    body:  
  {client_secret:config.stripe_test_secret_key,code:req.body.stripe, 
  grant_type:'authorization_code'}
  }, (error, response, body) => {
    if(body.error){
      return res.status('400').json({
        error: body.error_description
      })
    }
    req.body.stripe_seller = body
    next()
  })
}

向 Stripe 发出的 POST API 调用需要平台的秘钥和检索到的授权码来完成授权,并返回连接账户的凭据,然后将其附加到请求体中,以便用户可以在next()方法中更新。

有了这些凭据,应用程序可以代表卖家在客户信用卡上创建收费。

用于结账的 Stripe Card Elements

在结账过程中,为了从用户那里收集信用卡详细信息,我们将使用 Stripe 的Card Elements来在结账表单中添加信用卡字段。为了将Card Elements与我们的 React 界面集成,我们将利用react-stripe-elements npm 模块:

npm install --save react-stripe-elements

我们还需要在template.js中注入Stripe.js代码,以便在前端代码中访问 Stripe:

<script id="stripe-js" src="https://js.stripe.com/v3/" async></script>

对于 MERN Marketplace,Stripe 仅在购物车视图中需要,在那里Checkout组件需要它来渲染Card Elements并处理卡片详细信息。因此,在Cart组件挂载后,我们将使用应用程序的 Stripe API 密钥初始化 Stripe 实例,在其componentDidMount中。

mern-marketplace/client/cart/Cart.js

componentDidMount = () => {
    if (window.Stripe) {
      this.setState({stripe: 
     window.Stripe(config.stripe_test_api_key)})
    } else {
      document.querySelector('#stripe-js')
     .addEventListener('load', () 
     => {
        this.setState({stripe: 
     window.Stripe(config.stripe_test_api_key)})
      })
    }
 }

Cart.js中添加的Checkout组件应该使用react-stripe-elements中的StripeProvider组件进行包装,以便Checkout中的Elements可以访问 Stripe 实例。

mern-marketplace/client/cart/Cart.js

<StripeProvider stripe={this.state.stripe}> 
     <Checkout/>
</StripeProvider>

然后,在Checkout组件中,我们将使用 Stripe 的Elements组件。使用 Stripe 的Card Elements将使应用程序能够收集用户的信用卡详细信息,并使用 Stripe 实例对卡片信息进行标记,而不是在我们自己的服务器上处理。关于在结账流程中收集卡片详细信息和生成卡片令牌的实现将在结账创建新订单部分讨论。

Stripe 客户记录卡片详细信息

在结账流程结束时下订单时,生成的卡片令牌将被用来创建或更新代表我们用户的 Stripe 客户(stripe.com/docs/api#customers),这是一个存储信用卡信息的好方法(stripe.com/docs/saving-cards),以便进一步使用,比如在卖家从他们的商店处理已订购的产品时,仅为购物车中的特定产品创建收费。这消除了在自己的服务器上安全存储用户信用卡详细信息的复杂性。

更新用户模型

为了在我们的数据库中跟踪用户对应的 StripeCustomer信息,我们将使用以下字段更新用户模型:

stripe_customer: {},

更新用户控制器

当用户在输入信用卡详细信息后下订单时,我们将创建一个新的或更新现有的 Stripe 客户。为了实现这一点,我们将更新用户控制器,添加一个stripeCustomer方法,该方法将在我们的服务器收到请求创建订单 API(在创建新订单部分讨论)时,在创建订单之前被调用。

stripeCustomer控制器方法中,我们将需要使用stripe npm 模块:

npm install stripe --save

安装stripe模块后,需要将其导入到用户控制器文件中,并使用应用程序的 Stripe 秘钥初始化stripe实例。

mern-marketplace/server/controllers/user.controller.js

import stripe from 'stripe'
const myStripe = stripe(config.stripe_test_secret_key)

stripeCustomer控制器方法将首先检查当前用户是否已在数据库中存储了相应的 Stripe 客户,然后使用从前端收到的卡片令牌来创建一个新的 Stripe 客户或更新现有的客户。

创建一个新的 Stripe 客户

如果当前用户没有相应的 Stripe客户,换句话说,stripe_customer字段没有存储值,我们将使用 Stripe 的创建客户 API(stripe.com/docs/api#create_customer)。

mern-marketplace/server/controllers/user.controller.js:

myStripe.customers.create({
            email: req.profile.email,
            source: req.body.token
      }).then((customer) => {
          User.update({'_id':req.profile._id},
            {'$set': { 'stripe_customer': customer.id }},
            (err, order) => {
              if (err) {
                return res.status(400).send({
                  error: errorHandler.getErrorMessage(err)
                })
              }
              req.body.order.payment_id = customer.id
              next()
        })
})

如果 Stripe 客户成功创建,我们将通过将 Stripe 客户 ID 引用存储在stripe_customer字段中来更新当前用户的数据。我们还将将此客户 ID 添加到正在下订单的订单中,以便更简单地创建与订单相关的收费。

更新现有的 Stripe 客户

对于现有的 Stripe 客户,换句话说,当前用户在stripe_customer字段中存储了一个值,我们将使用 Stripe API 来更新 Stripe 客户。

mern-marketplace/server/controllers/user.controller.js:

 myStripe.customers.update(req.profile.stripe_customer, {
       source: req.body.token
     }, 
       (err, customer) => {
         if(err){
           return res.status(400).send({
             error: "Could not update charge details"
           })
         }
         req.body.order.payment_id = customer.id
         next()
       })

一旦 Stripe 客户成功更新,我们将在next()调用中将客户 ID 添加到正在创建的订单中。

虽然这里没有涉及,但 Stripe 客户功能可以进一步用于允许用户从应用程序中存储和更新他们的信用卡信息。

为每个处理的产品创建一个收费

当卖家通过处理其商店中订购的产品更新订单时,应用程序将代表卖家在客户的信用卡上为产品的成本创建一个收费。为了实现这一点,我们将更新user.controller.js文件,使用createCharge控制器方法来使用 Stripe 的创建收费 API,并需要卖家的 Stripe 账户 ID 以及买家的 Stripe 客户 ID。

mern-marketplace/server/controllers/user.controller.js:

const createCharge = (req, res, next) => {
  if(!req.profile.stripe_seller){
    return res.status('400').json({
      error: "Please connect your Stripe account"
    })
  }
  myStripe.tokens.create({
    customer: req.order.payment_id,
  }, {
    stripe_account: req.profile.stripe_seller.stripe_user_id,
  }).then((token) => {
      myStripe.charges.create({
        amount: req.body.amount * 100, //amount in cents
        currency: "usd",
        source: token.id,
      }, {
        stripe_account: req.profile.stripe_seller.stripe_user_id,
      }).then((charge) => {
        next()
      })
  })
}

如果卖家尚未连接他们的 Stripe 账户,createCharge方法将返回 400 错误响应,以指示需要连接 Stripe 账户。

为了能够代表卖家的 Stripe 账户向 Stripe 客户收费,我们首先需要使用客户 ID 和卖家的 Stripe 账户 ID 生成一个 Stripe 令牌,然后使用该令牌创建一个收费。

当服务器收到请求将产品状态更改为处理中的订单更新请求时,将调用createCharge控制器方法(关于此订单更新请求的 API 实现将在按商店排序的订单部分讨论)。

这涵盖了与 MERN Marketplace 特定用例的支付处理实现相关的所有 Stripe 相关概念。现在我们将继续允许用户完成结账并下订单。

结账

已登录并且已将商品添加到购物车的用户将能够开始结账流程。结账表单将收集客户详细信息、送货地址信息和信用卡信息:

初始化结账详细信息

Checkout组件中,我们将在收集表单详细信息之前,在状态中初始化checkoutDetails对象。

mern-marketplace/client/cart/Checkout.js

state = {
    checkoutDetails: {customer_name: '', customer_email:'', 
                      delivery_address: {street: '', city: '', state: 
                        '', zipcode: '', country:''}},
  }

组件挂载后,我们将根据当前用户的详细信息预填充客户详细信息,并将当前购物车商品添加到checkoutDetails中。

mern-marketplace/client/cart/Checkout.js

componentDidMount = () => {
    let user = auth.isAuthenticated().user
    let checkoutDetails = this.state.checkoutDetails
    checkoutDetails.products = cart.getCart()
    checkoutDetails.customer_name = user.name
    checkoutDetails.customer_email = user.email
    this.setState({checkoutDetails: checkoutDetails})
}

客户信息

在结账表单中,我们将添加文本字段以收集客户姓名和电子邮件。

mern-marketplace/client/cart/Checkout.js

<TextField id="name" label="Name" value={this.state.checkoutDetails.customer_name} onChange={this.handleCustomerChange('customer_name')}/>
<TextField id="email" type="email" label="Email" value={this.state.checkoutDetails.customer_email} onChange={this.handleCustomerChange('customer_email')}/><br/>

当用户更新值时,handleCustomerChange方法将更新状态中的相关详细信息:

handleCustomerChange = name => event => {
    let checkoutDetails = this.state.checkoutDetails
    checkoutDetails[name] = event.target.value || undefined
    this.setState({checkoutDetails: checkoutDetails})
}

送货地址

为了从用户那里收集送货地址,我们将在结账表单中添加以下文本字段以收集街道地址、城市、邮政编码、州和国家。

mern-marketplace/client/cart/Checkout.js

<TextField id="street" label="Street Address" value={this.state.checkoutDetails.delivery_address.street} onChange={this.handleAddressChange('street')}/>
<TextField id="city" label="City" value={this.state.checkoutDetails.delivery_address.city} onChange={this.handleAddressChange('city')}/>
<TextField id="state" label="State" value={this.state.checkoutDetails.delivery_address.state} onChange={this.handleAddressChange('state')}/>
<TextField id="zipcode" label="Zip Code" value={this.state.checkoutDetails.delivery_address.zipcode} onChange={this.handleAddressChange('zipcode')}/>
<TextField id="country" label="Country" value={this.state.checkoutDetails.delivery_address.country} onChange={this.handleAddressChange('country')}/>

当用户更新这些地址字段时,handleAddressChange方法将更新状态中的相关详细信息。

mern-marketplace/client/cart/Checkout.js

handleAddressChange = name => event => {
    let checkoutDetails = this.state.checkoutDetails
    checkoutDetails.delivery_address[name] = event.target.value || 
    undefined
    this.setState({checkoutDetails: checkoutDetails})
}

PlaceOrder 组件

将使用来自react-stripe-elements的 Stripe 的CardElement组件将信用卡字段添加到结账表单中。

CardElement组件必须是使用injectStripe higher-order component (HOC)构建的支付表单组件的一部分,并且使用Elements组件进行包装。因此,我们将创建一个名为PlaceOrder的组件,其中包含injectStripe,它将包含 Stripe 的CardElementPlaceOrder按钮。

mern-marketplace/client/cart/PlaceOrder.js

class **PlaceOrder** extends Component { … } export default **injectStripe**(withStyles(styles)(PlaceOrder))

然后我们将在结账表单中添加PlaceOrder组件,将checkoutDetails对象作为 prop 传递给它,并使用来自react-stripe-elementsElements组件进行包装。

mern-marketplace/client/cart/Checkout.js

<Elements> <PlaceOrder checkoutDetails={this.state.checkoutDetails} /> </Elements>

injectStripe HOC 提供了this.props.stripe属性,用于管理Elements组。这将允许我们在PlaceOrder中调用this.props.stripe.createToken来提交卡片详情到 Stripe 并获取卡片令牌。

Stripe CardElement 组件

Stripe 的CardElement是自包含的,因此我们只需将其添加到PlaceOrder组件中,然后根据需要添加样式,卡片详情输入就会被处理。

mern-marketplace/client/cart/PlaceOrder.js

<CardElement className={classes.StripeElement}
      {...{style: {
      base: {
        color: '#424770',
        letterSpacing: '0.025em',
        '::placeholder': {
          color: '#aab7c4',
        },
      },
      invalid: {
        color: '#9e2146',
      },
    }}}/>

下订单

PlaceOrder组件中的CardElement之后,也放置了“下订单”按钮。

mern-marketplace/client/cart/PlaceOrder.js

<Button color="secondary" variant="raised" onClick={this.placeOrder}>Place Order</Button>

点击“下订单”按钮将调用placeOrder方法,该方法将尝试使用stripe.createToken对卡片详情进行标记。如果失败,用户将被告知错误,但如果成功,结账详情和生成的卡片令牌将被发送到我们服务器的创建订单 API(在下一节中介绍)。

mern-marketplace/client/cart/PlaceOrder.js

placeOrder = ()=>{
  this.props.stripe.createToken().then(payload => {
      if(payload.error){
        this.setState({error: payload.error.message})
      }else{
        const jwt = auth.isAuthenticated()
        create({userId:jwt.user._id}, {
          t: jwt.token
        }, this.props.checkoutDetails, payload.token.id).then((data) => 
        {
          if (data.error) {
            this.setState({error: data.error})
          } else {
            cart.emptyCart(()=> {
              this.setState({'orderId':data._id,'redirect': true})
            })
          }
        })
      }
  })
}

client/order/api-order.js中定义了create fetch 方法,该方法向后端的创建订单 API 发出 POST 请求。它将结账详情、卡片令牌和用户凭据作为参数,并将其发送到/api/orders/:userId的 API。

mern-marketplace/client/order/api-order.js

const create = (params, credentials, order, token) => {
  return fetch('/api/orders/'+params.userId, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: JSON.stringify({order: order, token:token})
    })
    .then((response) => {
      return response.json()
    }).catch((err) => console.log(err))
}

购物车为空

如果创建订单 API 成功,我们将使用cart-helper.js中的emptyCart辅助方法清空购物车。

mern-marketplace/client/cart/cart-helper.js

emptyCart(cb) {
  if(typeof window !== "undefined"){
     localStorage.removeItem('cart')
     cb()
  }
}

emptyCart方法从localStorage中移除购物车对象,并通过执行传递的回调来更新视图的状态。

重定向到订单视图

下订单并清空购物车后,用户将被重定向到订单视图,该视图将显示刚刚下的订单的详细信息。

mern-marketplace/client/cart/PlaceOrder.js

if (this.state.redirect) {
      return (<Redirect to={'/order/' + this.state.orderId}/>)
}

这将表明结账过程已经完成,并成功调用了我们在服务器端设置的创建订单 API,用于在数据库中创建和存储订单。

创建新订单

当用户下订单时,将使用在结账时确认的订单详情来在数据库中创建新的订单记录,更新或创建用户的 Stripe 客户端,并减少已订购产品的库存数量。

订单模型

为了存储订单,我们将为订单模型定义一个 Mongoose 模式,记录客户详细信息以及用户帐户引用,交货地址信息,付款参考,创建和更新时间戳,以及一个订购产品的数组,其中每个产品的结构将在名为CartItemSchema的单独子模式中定义。

下订单者和客户

为了记录订单面向的客户的详细信息,我们将在Order模式中添加customer_namecustomer_email字段。

mern-marketplace/server/models/order.model.js

customer_name: { type: String, trim: true, required: 'Name is required' },
customer_email: { type: String, trim: true,
    match: [/.+\@.+\..+/, 'Please fill a valid email address'],
    required: 'Email is required' }

为了引用下订单的已登录用户,我们将添加一个ordered_by字段。

mern-marketplace/server/models/order.model.js

ordered_by: {type: mongoose.Schema.ObjectId, ref: 'User'}

交货地址

订单的交货地址信息将存储在交货地址子文档中,其中包括streetcitystatezipcodecountry字段。

mern-marketplace/server/models/order.model.js

delivery_address: {
    street: {type: String, required: 'Street is required'},
    city: {type: String, required: 'City is required'},
    state: {type: String},
    zipcode: {type: String, required: 'Zip Code is required'},
    country: {type: String, required: 'Country is required'}
  },

付款参考

付款信息将在订单更新时相关,卖家处理订购产品后需要创建费用时。我们将在Order模式的payment_id字段中记录与信用卡详细信息相关的 Stripe 客户 ID。

mern-marketplace/server/models/order.model.js

payment_id: {},

订购的产品

订单的主要内容将是订购产品的列表以及每个产品的数量等详细信息。我们将在Order模式的一个名为products的字段中记录此列表。每个产品的结构将在CartItemSchema中单独定义。

mern-marketplace/server/models/order.model.js

products: [CartItemSchema],

购物车项目模式

CartItem模式将代表每个订购的产品。它将包含对产品的引用,用户订购的产品数量,产品所属商店的引用以及状态。

mern-marketplace/server/models/order.model.js

const CartItemSchema = new mongoose.Schema({
  product: {type: mongoose.Schema.ObjectId, ref: 'Product'},
  quantity: Number,
  shop: {type: mongoose.Schema.ObjectId, ref: 'Shop'},
  status: {type: String,
    default: 'Not processed',
    enum: ['Not processed' , 'Processing', 'Shipped', 'Delivered', 
   'Cancelled']}
}) 
const CartItem = mongoose.model('CartItem', CartItemSchema)

产品的status只能具有枚举中定义的值,表示卖家更新的产品订购的当前状态。

在这里定义的Order模式将记录客户和卖家完成订购产品的购买步骤所需的详细信息。

创建订单 API

创建订单 API 路由在server/routes/order.routes.js中声明。订单路由将与用户路由非常相似。要在 Express 应用程序中加载订单路由,我们需要在express.js中挂载路由,就像我们为 auth 和 user 路由所做的那样。

mern-marketplace/server/express.js

app.use('/', orderRoutes)

当创建订单 API 在/api/orders/:userId接收到 POST 请求时,将按以下顺序执行一系列操作。

  • 确保用户已登录

  • 使用之前讨论过的stripeCustomer用户控制器方法,创建或更新 StripeCustomer

  • 使用decreaseQuanity产品控制器方法,更新所有订购产品的库存数量

  • 使用create订单控制器方法在订单集合中创建订单

路由将被定义如下。

mern-marketplace/server/routes/order.routes.js

router.route('/api/orders/:userId') 
    .post(authCtrl.requireSignin, userCtrl.stripeCustomer, 
          productCtrl.decreaseQuantity, orderCtrl.create)

为了检索与路由中的:userId参数相关联的用户,我们将使用userByID用户控制器方法,该方法从用户集合中获取用户,并将其附加到请求对象中,以便下一个方法访问。我们将在订单路由中添加它。

mern-marketplace/server/routes/order.routes.js

router.param('userId', userCtrl.userByID)

减少产品库存数量

我们将更新产品控制器文件,添加decreaseQuantity控制器方法,该方法将更新新订单中购买的所有产品的库存数量。

mern-marketplace/server/controllers/product.controller.js

const decreaseQuantity = (req, res, next) => {
  let bulkOps = req.body.order.products.map((item) => {
    return {
        "updateOne": {
            "filter": { "_id": item.product._id } ,
            "update": { "$inc": {"quantity": -item.quantity} }
        }
    }
   })
   Product.bulkWrite(bulkOps, {}, (err, products) => {
     if(err){
       return res.status(400).json({
         error: "Could not update product"
       })
     }
     next()
   })
}

在这种情况下,更新操作涉及在与产品数组匹配后对集合中的多个产品进行批量更新,我们将使用 MongoDB 中的bulkWrite方法,以便一次性向 MongoDB 服务器发送多个updateOne操作。首先使用map函数将需要的多个updateOne操作列在bulkOps中。这将比发送多个独立的保存或更新操作更快,因为使用bulkWrite()只需要一次往返到 MongoDB。

创建订单控制器方法

在订单控制器中定义的create控制器方法接收订单详情,创建新订单,并将其保存到 MongoDB 的订单集合中。

mern-marketplace/server/controllers/order.controller.js

const create = (req, res) => {
  req.body.order.user = req.profile
  const order = new Order(req.body.order)
  order.save((err, result) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.status(200).json(result)
  })
}

通过这样的实现,任何在 MERN Marketplace 上登录的用户都可以创建并将订单存储在后端。现在我们可以设置 API 来获取用户的订单列表、商店的订单列表,或者读取单个订单并将获取的数据显示在前端视图中。

商店的订单

市场的一个重要特性是允许卖家查看和更新他们在商店中收到的订单的状态。为了实现这一点,我们首先将设置 API 来按商店列出订单,然后在卖家更改已购买产品的状态时更新订单。

按商店列出 API

我们将实现一个 API 来获取特定商店的订单,这样经过身份验证的卖家可以查看他们每个商店的订单。对于这个 API 的请求将在'/api/orders/shop/:shopId接收,路由在order.routes.js中定义如下。

mern-marketplace/server/routes/order.routes.js

router.route('/api/orders/shop/:shopId') 
    .get(authCtrl.requireSignin, shopCtrl.isOwner, orderCtrl.listByShop)
router.param('shopId', shopCtrl.shopByID)

为了检索与路由中的:shopId参数相关联的商店,我们将使用shopByID商店控制器方法,该方法从商店集合中获取商店并将其附加到请求对象中,以便下一个方法访问。

listByShop控制器方法将检索具有与匹配商店 ID 购买的产品的订单,然后按日期从最近到最旧的顺序填充每个产品的 ID、名称和价格字段。

mern-marketplace/server/controllers/order.controller.js

const listByShop = (req, res) => {
  Order.find({"products.shop": req.shop._id})
  .populate({path: 'products.product', select: '_id name price'})
  .sort('-created')
  .exec((err, orders) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(orders)
  })
}

为了在前端获取这个 API,我们将在api-order.js中添加一个相应的listByShop方法,用于在ShopOrders组件中显示每个商店的订单。

mern-marketplace/client/order/api-order.js

const listByShop = (params, credentials) => {
  return fetch('/api/orders/shop/'+params.shopId, {
    method: 'GET',
    headers: {
      'Accept': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    }
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

ShopOrders 组件

卖家将在ShopOrders组件中查看他们的订单列表,每个订单只显示与商店相关的已购买产品,并允许卖家使用可能状态值的下拉菜单更改产品的状态:

我们将在MainRouter中更新一个PrivateRoute,以在/seller/orders/:shop/:shopId路由处加载ShopOrders组件。

mern-marketplace/client/MainRouter.js

<PrivateRoute path="/seller/orders/:shop/:shopId" component={ShopOrders}/>

列出订单

ShopOrders组件挂载时,我们将使用listByShop获取方法加载相关订单,并将检索到的订单设置为状态。

mern-marketplace/client/order/ShopOrders.js

 loadOrders = () => {
    const jwt = auth.isAuthenticated()
    listByShop({
      shopId: this.match.params.shopId
    }, {t: jwt.token}).then((data) => {
      if (data.error) {
        console.log(data)
      } else {
        this.setState({orders: data})
      }
    })
 }

在视图中,我们将遍历订单列表,并在Material-UI的可折叠列表中呈现每个订单,点击时会展开。

mern-marketplace/client/order/ShopOrders.js

<Typography type="title"> Orders in {this.match.params.shop} </Typography>
<List dense> {this.state.orders.map((order, index) => { return 
    <span key={index}>
        <ListItem button onClick={this.handleClick(index)}>
           <ListItemText primary={'Order # '+order._id} 
                 secondary={(new Date(order.created)).toDateString()}/>
           {this.state.open == index ? <ExpandLess /> : <ExpandMore />}
        </ListItem>
        <Collapse component="li" in={this.state.open == index} 
       timeout="auto" unmountOnExit>
           <ProductOrderEdit shopId={this.match.params.shopId} 
           order={order} orderIndex={index} 
           updateOrders={this.updateOrders}/>
           <Typography type="subheading"> Deliver to:</Typography>
           <Typography type="subheading" color="primary">
               {order.customer_name} ({order.customer_email})
          </Typography>
           <Typography type="subheading" color="primary">
               {order.delivery_address.street}</Typography>
           <Typography type="subheading" color="primary">
               {order.delivery_address.city}, 
           {order.delivery_address.state}
               {order.delivery_address.zipcode}</Typography>
           <Typography type="subheading" color="primary">
               {order.delivery_address.country}</Typography>
        </Collapse>
    </span>})}
</List>

每个展开的订单将显示订单详情和ProductOrderEdit组件。ProductOrderEdit组件将显示已购买的产品,并允许卖家编辑每个产品的状态。updateOrders方法作为属性传递给ProductOrderEdit组件,以便在更改产品状态时可以更新状态。

mern-marketplace/client/order/ShopOrders.js

updateOrders = (index, updatedOrder) => {
    let orders = this.state.orders 
    orders[index] = updatedOrder 
    this.setState({orders: orders}) 
}

产品订单编辑组件

ProductOrderEdit组件将订单对象作为属性,并遍历订单的产品数组,仅显示从当前商店购买的产品,以及更改每个产品状态值的下拉菜单。

mern-marketplace/client/order/ProductOrderEdit.js

{this.props.order.products.map((item, index) => { return <span key={index}> 
     { item.shop == this.props.shopId && 
          <ListItem button>
              <ListItemText primary={ <div>
                     <img src=
                    {'/api/product/image/'+item.product._id}/> 
                     {item.product.name}
                     <p>{"Quantity: "+item.quantity}</p>
              </div>}/>
              <TextField id="select-status" select
                   label="Update Status" value={item.status}
                   onChange={this.handleStatusChange(index)}
                   SelectProps={{
                       MenuProps: { className: classes.menu },
                   }}>
                      {this.state.statusValues.map(option => (
                          <MenuItem key={option} value={option}>
                            {option}
                          </MenuItem>
                      ))}
              </TextField>
          </ListItem>}

在加载ProductOrderEdit组件时,从服务器获取可能的状态值列表,并设置为statusValues状态,以在下拉菜单中呈现为MenuItem

mern-marketplace/client/order/ProductOrderEdit.js

loadStatusValues = () => {
    getStatusValues().then((data) => {
      if (data.error) {
        this.setState({error: "Could not get status"})
      } else {
        this.setState({statusValues: data, error: ''})
      }
    })
}

当从可能的状态值中选择一个选项时,将调用handleStatusChange方法来更新状态中的订单,并根据所选状态的值发送请求到适当的后端 API。

mern-marketplace/client/order/ProductOrderEdit.js

handleStatusChange = productIndex => event => {
    let order = this.props.order 
    order.products[productIndex].status = event.target.value 
    let product = order.products[productIndex] 
    const jwt = auth.isAuthenticated() 
    if(event.target.value == "Cancelled"){
       cancelProduct({ shopId: this.props.shopId, 
       productId: product.product._id }, 
       {t: jwt.token}, 
       {cartItemId: product._id, status: 
       event.target.value, 
       quantity: product.quantity
       }).then((data) => { 
       if (data.error) {
       this.setState({error: "Status not updated, 
       try again"})
       } else {
 this.props.updateOrders(this.props.orderIndex, order)      this.setState(error: '') 
       } 
       }) 
       } else if(event.target.value == "Processing"){
       processCharge({ userId: jwt.user._id, shopId: 
       this.props.shopId, orderId: order._id }, 
       { t: jwt.token}, 
       { cartItemId: product._id, 
       amount: (product.quantity *
       product.product.price)
       status: event.target.value }).then((data) => { ... 
       })
       } else {
       update({ shopId: this.props.shopId }, {t: 
       jwt.token}, 
       { cartItemId: product._id, 
       status: event.target.value}).then((data) => { ... })
      }
}

api-order.js中定义了cancelProductprocessChargeupdate获取方法,以调用后端对应的 API 来更新取消产品的库存数量,在处理产品时在客户的信用卡上创建一个费用,并更新订单以更改产品状态。

已订购产品的 API

允许卖家更新产品状态将需要设置四个不同的 API,包括一个用于检索可能状态值的 API。然后实际状态更新将需要处理订单本身的更新 API,因为状态已更改,以启动相关操作,例如增加取消产品的库存数量,并在处理产品时在客户的信用卡上创建一个费用。

获取状态值

已订购产品的可能状态值在CartItem模式中设置为枚举,并且为了在下拉视图中显示这些值作为选项,我们将在/api/order/status_values设置一个 GET API 路由,以检索这些值。

mern-marketplace/server/routes/order.routes.js

router.route('/api/order/status_values')
    .get(orderCtrl.getStatusValues)

getStatusValues控制器方法将从CartItem模式的status字段返回枚举值。

mern-marketplace/server/controllers/order.controller.js

const getStatusValues = (req, res) => {
  res.json(CartItem.schema.path('status').enumValues)
}

我们还将在api-order.js中设置一个fetch方法,这在视图中用于向 API 路由发出请求。

mern-marketplace/client/order/api-order.js

const getStatusValues = () => {
  return fetch('/api/order/status_values', {
    method: 'GET'
  }).then((response) => {
    return response.json()
  }).catch((err) => console.log(err))
}

更新订单状态

当产品的状态更改为除处理中已取消之外的任何值时,将直接向'/api/order/status/:shopId'发送 PUT 请求,以更新数据库中的订单,假设当前用户是已验证的拥有订购产品的商店的所有者。

mern-marketplace/server/routes/order.routes.js

router.route('/api/order/status/:shopId')
    .put(authCtrl.requireSignin, shopCtrl.isOwner, orderCtrl.update)

update控制器方法将查询订单集合,并找到与更新产品匹配的CartItem对象的订单,并设置订单中products数组中匹配的CartItemstatus值。

mern-marketplace/server/controllers/order.controller.js

const update = (req, res) => {
  Order.update({'products._id':req.body.cartItemId}, {'$set': {
        'products.$.status': req.body.status
    }}, (err, order) => {
      if (err) {
        return res.status(400).send({
          error: errorHandler.getErrorMessage(err)
        })
      }
      res.json(order)
    })
}

api-order.js中,我们将添加一个update fetch 方法,以使用从视图传递的必需参数调用此更新 API。

mern-marketplace/client/order/api-order.js

const update = (params, credentials, product) => {
  return fetch('/api/order/status/' + params.shopId, {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify(product)
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  }) 
}

取消产品订单

当卖家决定取消产品的订单时,将发送一个 PUT 请求到/api/order/:shopId/cancel/:productId,以便增加产品库存数量,并在数据库中更新订单。

mern-marketplace/server/routes/order.routes.js

router.route('/api/order/:shopId/cancel/:productId')
       .put(authCtrl.requireSignin, shopCtrl.isOwner,
       productCtrl.increaseQuantity, orderCtrl.update)
       router.param('productId', productCtrl.productByID)

为了检索与路由中的productId参数相关联的产品,我们将使用productByID产品控制器方法。

增加数量的控制器方法被添加到product.controller.js中。它在产品集合中通过匹配的 ID 找到产品,并将数量值增加到客户订购的数量,现在该产品的订单已被取消。

mern-marketplace/server/controllers/product.controller.js

const increaseQuantity = (req, res, next) => {
  Product.findByIdAndUpdate(req.product._id, {$inc: 
  {"quantity": req.body.quantity}}, {new: true})
    .exec((err, result) => {
      if (err) {
        return res.status(400).json({
          error: errorHandler.getErrorMessage(err)
        })
      }
      next()
    })
}

从视图中,我们将使用在api-order.js中添加的相应 fetch 方法来调用取消产品订单 API。

mern-marketplace/client/order/api-order.js

const cancelProduct = (params, credentials, product) => {
  return fetch('/api/order/'+params.shopId+'/cancel/'+params.productId, {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify(product)
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

为产品处理收费

当卖家将产品的状态更改为处理中时,我们将建立一个后端 API,不仅更新订单,还会为产品的价格乘以订购数量在客户的信用卡上创建一个收费。

mern-marketplace/server/routes/order.routes.js

router.route('/api/order/:orderId/charge/:userId/:shopId')
            .put(authCtrl.requireSignin, shopCtrl.isOwner,     
            userCtrl.createCharge, orderCtrl.update)
router.param('orderId', orderCtrl.orderByID)

为了检索与路由中的orderId参数相关联的订单,我们将使用orderByID订单控制器方法,该方法从订单集合中获取订单并将其附加到请求对象中,以便由next方法访问,如下所示。

mern-marketplace/server/controllers/order.controller.js:

const orderByID = (req, res, next, id) => {
  Order.findById(id).populate('products.product', 'name price')
       .populate('products.shop', 'name')
       .exec((err, order) => {
          if (err || !order)
            return res.status('400').json({
              error: "Order not found"
            })
          req.order = order
          next()
       })
}

此过程收费 API 将在/api/order/:orderId/charge/:userId/:shopId接收 PUT 请求,并在成功验证用户后,通过调用createCharge用户控制器来创建收费,如前面的使用 Stripe 进行付款部分所讨论的,最后使用update方法更新订单。

从视图中,我们将在api-order.js中使用processCharge fetch 方法,并提供所需的路由参数值、凭据和产品详情,包括要收费的金额。

mern-marketplace/client/order/api-order.js

const processCharge = (params, credentials, product) => {
  return fetch('/api/order/'+params.orderId+'/charge/'+params.userId+'/'
    +params.shopId, {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify(product)
  }).then((response) => {
    return response.json()
  }).catch((err) => {
    console.log(err)
  })
}

卖家可以查看其店铺中收到的产品订单,并可以轻松更新每个产品订单的状态,而应用程序会处理其他任务,例如更新库存数量和发起付款。这涵盖了 MERN Marketplace 应用程序的基本订单管理功能,可以根据需要进一步扩展。

查看订单详情

随着订单集合和数据库访问的设置完成,向前推进很容易添加每个用户的订单列表功能,并在单独的视图中显示单个订单的详细信息,用户可以在该视图中跟踪每个已订购产品的状态。

遵循本书中反复出现的步骤,设置后端 API 以检索数据并在前端使用它来构建前端视图,您可以根据需要开发与订单相关的视图,并从 MERN Marketplace 应用程序代码中的这些示例视图的快照中获得灵感:

在本章和第六章中开发的 MERN Marketplace 应用程序,通过在 MERN 骨架应用程序的基础上构建,涵盖了标准在线市场应用程序的关键功能。这反过来展示了 MERN 堆栈如何扩展以包含复杂功能。

总结

在本章中,我们扩展了 MERN Marketplace 应用程序,并探讨了如何为买家添加购物车,使用信用卡付款的结账流程,以及在线市场应用程序中卖家的订单管理。

我们发现 MERN 堆栈技术如何与第三方集成良好,因为我们实现了购物车结账流程,并使用 Stripe 提供的工具处理已订购产品的信用卡付款,用于管理在线付款。

我们还解锁了 MERN 的更多可能性,例如在 MongoDB 中进行优化的批量写操作,以响应单个 API 调用更新多个文档。这使我们能够一次性减少多个产品的库存数量,例如当用户从不同商店订购多个产品时。

在 MERN Marketplace 应用程序中开发的市场功能揭示了这种堆栈和结构如何被利用来设计和构建不断增长的应用程序,通过添加可能是简单或更复杂的特性。

在下一章中,我们将借鉴本书迄今为止所学到的经验,通过扩展 MERN 骨架构建媒体流应用程序,探索更高级的可能性。

第八章:构建媒体流应用程序

上传和流媒体内容,特别是视频内容,已经成为互联网文化的一个日益增长的部分。从个人分享个人视频内容到娱乐行业在在线流媒体服务上发布商业内容,我们都依赖于能够实现平稳上传和流媒体的网络应用程序。MERN 堆栈技术中的功能可以用于构建和集成这些核心流媒体功能到任何基于 MERN 的 Web 应用程序中。

在这一章中,我们将通过扩展 MERN 骨架应用程序来覆盖以下主题,实现基本的媒体上传和流媒体:

  • 将视频上传到 MongoDB GridFS

  • 存储和检索媒体详情

  • 从 GridFS 流式传输到基本媒体播放器

MERN Mediastream

我们将通过扩展基本应用程序来构建 MERN Mediastream 应用程序。这将是一个简单的视频流应用程序,允许注册用户上传视频,任何浏览应用程序的人都可以观看:

完整的 MERN Mediastream 应用程序的代码可在 GitHub 上找到github.com/shamahoque/…。本章讨论的实现可以在同一存储库的simple-mediastream-gridfs分支中访问。您可以克隆此代码,并在本章的其余部分中阅读代码解释时运行应用程序。

为了实现与媒体上传、编辑和流媒体相关的功能所需的视图,我们将通过扩展和修改 MERN 骨架应用程序中的现有 React 组件来开发。下图显示了构成本章中开发的 MERN Mediastream 前端的所有自定义 React 组件的组件树:

上传和存储媒体

在 MERN Mediastream 上注册的用户将能够从其本地文件上传视频,直接在 MongoDB 上使用 GridFS 存储视频和相关详情。

媒体模型

为了存储媒体详情,我们将在server/models/media.model.js中为媒体模型添加一个 Mongoose 模式,其中包含用于记录媒体标题、描述、流派、观看次数、创建时间、更新时间以及发布媒体的用户的引用字段。

mern-mediastream/server/models/media.model.js

import mongoose from 'mongoose'
import crypto from 'crypto'
const MediaSchema = new mongoose.Schema({
  title: {
    type: String,
    required: 'title is required'
  },
  description: String,
  genre: String,
  views: {type: Number, default: 0},
  postedBy: {type: mongoose.Schema.ObjectId, ref: 'User'},
  created: {
    type: Date,
    default: Date.now
  },
  updated: {
    type: Date
  }
})

export default mongoose.model('Media', MediaSchema)

MongoDB GridFS 用于存储大文件

在之前的章节中,我们讨论了用户上传的文件可以直接存储在 MongoDB 中作为二进制数据。但这仅适用于小于 16 MB 的文件。为了在 MongoDB 中存储更大的文件,我们需要使用 GridFS。

GridFS 通过将文件分成最大为 255 KB 的几个块,然后将每个块存储为单独的文档来在 MongoDB 中存储大文件。当需要响应 GridFS 查询检索文件时,根据需要重新组装块。这打开了根据需要获取和加载文件的部分而不是检索整个文件的选项。

在 MERN Mediastream 中存储和检索视频文件时,我们将利用 GridFS 存储视频文件,并根据用户跳转到和开始播放的部分来流式传输视频的部分。

我们将使用gridfs-stream npm 模块将 GridFS 功能添加到我们的服务器端代码中:

npm install gridfs-stream --save

为了将gridfs-stream与我们的数据库连接配置,我们将使用 Mongoose 将其链接如下。

mern-mediastream/server/controllers/media.controller.js

import mongoose from 'mongoose'
import Grid from 'gridfs-stream'
Grid.mongo = mongoose.mongo
let gridfs = null
mongoose.connection.on('connected', () => {
  gridfs = Grid(mongoose.connection.db)
})

gridfs对象将提供访问 GridFS 所需的功能,以便在创建新媒体时存储文件,并在需要向用户流回媒体时获取文件。

创建媒体 API

我们将在 Express 服务器上设置一个创建媒体 API,该 API 将在'/api/media/new/:userId'接收包含媒体字段和上传的视频文件的多部分内容的 POST 请求。

创建媒体的路由

server/routes/media.routes.js中,我们将添加创建路由,并利用用户控制器中的userByID方法。userByID方法处理 URL 中传递的:userId参数,并从数据库中检索关联的用户。

mern-mediastream/server/routes/media.routes.js

router.route('/api/media/new/:userId')
        .post(authCtrl.requireSignin, mediaCtrl.create)
router.param('userId', userCtrl.userByID)

对创建路由的 POST 请求将首先确保用户已登录,然后在媒体控制器中启动create方法。

类似于用户和认证路由,我们将不得不在express.js中将媒体路由挂载到 Express 应用程序上。

mern-mediastream/server/express.js

app.use('/', mediaRoutes)

处理创建请求的控制器方法

媒体控制器中的create方法将使用formidable npm 模块解析包含媒体详细信息和用户上传的视频文件的多部分请求体:

npm install formidable --save

formidable解析的表单数据接收的媒体字段将用于生成新的媒体对象并保存到数据库中。

mern-mediastream/server/controllers/media.controller.js

const create = (req, res, next) => {
  let form = new formidable.IncomingForm()
    form.keepExtensions = true
    form.parse(req, (err, fields, files) => {
      if (err) {
        return res.status(400).json({
          error: "Video could not be uploaded"
        })
      }
      let media = new Media(fields)
      media.postedBy= req.profile
      if(files.video){
        let writestream = gridfs.createWriteStream({_id: media._id})
        fs.createReadStream(files.video.path).pipe(writestream)
      }
      media.save((err, result) => {
        if (err) {
          return res.status(400).json({
            error: errorHandler.getErrorMessage(err)
          })
        }
        res.json(result)
      })
    })
}

如果请求中有文件,formidable将在文件系统中临时存储它,我们将使用媒体对象的 ID 创建一个gridfs.writeStream来读取临时文件并将其写入 MongoDB。这将在 MongoDB 中生成关联的块和文件信息文档。当需要检索此文件时,我们将使用媒体 ID 来识别它。

在视图中创建 API 获取

api-media.js中,我们将添加一个相应的方法,通过传递视图中的多部分表单数据来向创建 API 发出POST请求。

mern-mediastream/client/user/api-user.js

const create = (params, credentials, media) => {
  return fetch('/api/media/new/'+ params.userId, {
    method: 'POST',
    headers: {
      'Accept': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: media
  }).then((response) => {
    return response.json() 
  }).catch((err) => {
    console.log(err) 
  }) 
}

当用户提交新的媒体表单以上传新视频时,将使用此create获取方法。

新媒体表单视图

注册用户将在菜单中看到一个链接,用于添加新媒体。这个链接将带他们到新的媒体表单视图,并允许他们上传视频文件以及视频的详细信息。

添加媒体菜单按钮

client/core/Menu.js中,我们将更新现有的代码,以添加添加媒体按钮链接的 My Profile 和 Signout 链接:

只有在用户当前已登录时才会在菜单上呈现。

mern-mediastream/client/core/Menu.js/

<Link to="/media/new">
     <Button style={isActive(history, "/media/new")}>
        <AddBoxIcon style={{marginRight: '8px'}}/> Add Media
     </Button>
</Link>

NewMedia 视图的 React 路由

当用户点击添加媒体链接时,我们将更新MainRouter文件以添加/media/new React 路由,这将渲染NewMedia组件,将用户带到新的媒体表单视图。

mern-mediastream/client/MainRouter.js

<PrivateRoute path="/media/new" component={NewMedia}/>

由于这个新的媒体表单只能由已登录用户访问,我们将把它添加为PrivateRoute

NewMedia 组件

NewMedia组件中,我们将渲染一个表单,允许用户输入标题、描述和流派,并从本地文件系统上传视频文件:

我们将使用 Material-UI 的Button和 HTML5 的file input元素添加文件上传元素。

mern-mediastream/client/media/NewMedia.js

<input accept="video/*" 
       onChange={this.handleChange('video')} 
       id="icon-button-file" 
       type="file"
       style={{display: none}}/>
<label htmlFor="icon-button-file">
    <Button color="secondary" variant="raised" component="span">
       Upload <FileUpload/>
    </Button>
</label> 
<span>{this.state.video ? this.state.video.name : ''}</span>

TitleDescriptionGenre表单字段将添加TextField组件。

mern-mediastream/client/media/NewMedia.js

<TextField id="title" label="Title" value={this.state.title} 
           onChange={this.handleChange('title')} margin="normal"/><br/>
<TextField id="multiline-flexible" label="Description"
           multiline rows="2"
           value={this.state.description}
           onChange={this.handleChange('description')}/><br/>
<TextField id="genre" label="Genre" value={this.state.genre} 
           onChange={this.handleChange('genre')}/><br/>

这些表单字段的更改将通过handleChange方法进行跟踪。

mern-mediastream/client/media/NewMedia.js

handleChange = name => event => {
    const value = name === 'video'
      ? event.target.files[0]
      : event.target.value
    this.mediaData.set(name, value)
    this.setState({ [name]: value })
}

handleChange方法使用新值更新状态并填充mediaData,这是一个FormData对象。FormData API 确保要发送到服务器的数据以multipart/form-data编码类型所需的正确格式存储。这个mediaData对象在componentDidMount中初始化。

mern-mediastream/client/media/NewMedia.js:

componentDidMount = () => {
    this.mediaData = new FormData()
}

在表单提交时,将使用必要的凭据调用create fetch 方法,并将表单数据作为参数传递:

 clickSubmit = () => {
    const jwt = auth.isAuthenticated()
    create({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, this.mediaData).then((data) => {
      if (data.error) {
        this.setState({error: data.error})
      } else {
        this.setState({redirect: true, mediaId: data._id})
      }
    })
 }

在成功创建媒体后,用户可以根据需要重定向到不同的视图,例如,到一个带有新媒体详情的媒体视图。

mern-mediastream/client/media/NewMedia.js:

if (this.state.redirect) {
      return (<Redirect to={'/media/' + this.state.mediaId}/>)
}

为了允许用户流媒体和查看存储在 MongoDB 中的视频文件,接下来我们将实现如何在视图中检索和渲染视频。

检索和流媒体

在服务器上,我们将设置一个路由来检索单个视频文件,然后将其用作 React 媒体播放器中的源,以渲染流媒体视频。

获取视频 API

我们将在媒体路由中添加一个路由,以在'/api/medias/video/:mediaId'接收到 GET 请求时获取视频。

mern-mediastream/server/routes/media.routes.js:

router.route('/api/medias/video/:mediaId')
        .get(mediaCtrl.video)
router.param('mediaId', mediaCtrl.mediaByID)

路由 URL 中的:mediaId参数将在mediaByID控制器中处理,以从媒体集合中获取关联文档并附加到请求对象中,因此可以根据需要在video控制器方法中使用。

mern-mediastream/server/controllers/media.controller.js:

const mediaByID = (req, res, next, id) => {
  Media.findById(id).populate('postedBy', '_id name').exec((err, media) => {
    if (err || !media)
      return res.status('400').json({
        error: "Media not found"
      })
    req.media = media
    next()
  })
}

media.controller.js中的video控制器方法将使用gridfs在 MongoDB 中查找与mediaId关联的视频。然后,如果找到匹配的视频并且取决于请求是否包含范围标头,响应将发送回正确的视频块,并将相关内容信息设置为响应标头。

mern-mediastream/server/controllers/media.controller.js:

const video = (req, res) => {
  gridfs.findOne({
        _id: req.media._id
    }, (err, file) => {
        if (err) {
            return res.status(400).send({
                error: errorHandler.getErrorMessage(err)
            })
        }
        if (!file) {
            return res.status(404).send({
                error: 'No video found'
            })
        }

        if (req.headers['range']) {
            ...
            ... consider range headers and send only relevant chunks in 
           response ...
            ...
        } else {
            res.header('Content-Length', file.length)
            res.header('Content-Type', file.contentType)

            gridfs.createReadStream({
                _id: file._id
            }).pipe(res)
        }
    })
}

如果请求包含范围标头,例如当用户拖动到视频中间并从那一点开始播放时,我们需要将范围标头转换为与使用 GridFS 存储的正确块对应的起始和结束位置。然后,我们将这些起始和结束值作为范围传递给 gridfs-stream 的createReadStream方法,并且还使用附加文件详情设置响应标头,包括内容长度、范围和类型。

mern-mediastream/server/controllers/media.controller.js:

let parts = req.headers['range'].replace(/bytes=/, "").split("-")
let partialstart = parts[0]
let partialend = parts[1]

let start = parseInt(partialstart, 10)
let end = partialend ? parseInt(partialend, 10) : file.length - 1
let chunksize = (end - start) + 1

res.writeHead(206, {
    'Accept-Ranges': 'bytes',
 'Content-Length': chunksize,
 'Content-Range': 'bytes ' + start + '-' + end + '/' + file.length,
 'Content-Type': file.contentType
})

gridfs.createReadStream({
        _id: file._id,
        range: {
                 startPos: start,
                 endPos: end
                }
}).pipe(res)

最终的readStream管道传输到响应中可以直接在前端视图中使用基本的 HTML5 媒体播放器或 React 风格的媒体播放器进行渲染。

使用 React 媒体播放器来呈现视频

作为 npm 可用的 React 风格媒体播放器的一个很好的选择是ReactPlayer组件,可以根据需要进行自定义:

可以通过安装相应的npm模块在应用程序中使用它:

npm install react-player --save

对于使用浏览器提供的默认控件的基本用法,我们可以将其添加到应用程序中任何具有要呈现的媒体 ID 访问权限的 React 视图中:

<ReactPlayer url={'/api/media/video/'+media._id} controls/>

在下一章中,我们将探讨使用我们自己的控件自定义这个ReactPlayer的高级选项。

要了解有关ReactPlayer可能性的更多信息,请访问cookpete.com/react-playe…

媒体列表

在 MERN Mediastream 中,我们将添加相关媒体的列表视图,并为每个视频提供快照,以便访问者更容易地访问应用程序中的视频概述。我们将在后端设置列表 API 来检索不同的列表,例如单个用户上传的视频以及应用程序中观看次数最多的最受欢迎视频。然后,这些检索到的列表可以在MediaList组件中呈现,该组件将从父组件接收一个列表作为 prop,该父组件从特定 API 中获取列表:

在前面的屏幕截图中,Profile组件使用用户 API 列表来获取前面配置文件中看到的用户发布的媒体列表,并将接收到的列表传递给MediaList组件以呈现每个视频和媒体详细信息。

媒体列表组件

MediaList组件是一个可重用的组件,它将获取一个媒体列表并在视图中迭代每个项目进行呈现。在 MERN Mediastream 中,我们使用它来在主页视图中呈现最受欢迎的媒体列表,以及在用户配置文件中上传的媒体列表。

mern-mediastream/client/media/MediaList.js

<GridList cols={3}>
   {this.props.media.map((tile, i) => (
        <GridListTile key={i}>
          <Link to={"/media/"+tile._id}>
            <ReactPlayer url={'/api/media/video/'+tile._id} 
                         width='100%' height='inherit'/>
          </Link>
          <GridListTileBar 
            title={<Link to={"/media/"+tile._id}>{tile.title}</Link>}
            subtitle={<span>{tile.views} views 
                  <span style={{float: 'right'}}>{tile.genre}</span>}/>
        </GridListTile>
    ))}
</GridList>

MediaList组件使用 Material-UI 的GridList组件,它在 props 中迭代列表,并为列表中的每个项目呈现媒体详细信息,以及一个ReactPlayer组件,用于呈现视频 URL 而不显示任何控件。在视图中,这为访问者提供了媒体的简要概述,也可以一瞥视频内容。

列出热门媒体

为了从数据库中检索特定的媒体列表,我们需要在服务器上设置相关的 API。对于热门媒体,我们将设置一个路由,接收/api/media/popular的 GET 请求。

mern-mediastream/server/routes/media.routes.js

 router.route('/api/media/popular')
          .get(mediaCtrl.listPopular)

listPopular控制器方法将查询媒体集合,以检索具有整个集合中最高views的十个媒体文档。

mern-mediastream/server/controllers/media.controller.js

const listPopular = (req, res) => {
  Media.find({}).limit(10)
  .populate('postedBy', '_id name')
  .sort('-views')
  .exec((err, posts) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(posts)
  })
}

为了在视图中使用此 API,我们将在api-media.js中设置相应的 fetch 方法。

mern-mediastream/client/media/api-media.js

const listPopular = (params) => {
  return fetch('/api/media/popular', {
    method: 'GET',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    }
  }).then(response => {
    return response.json() 
  }).catch((err) => console.log(err)) 
}

Home组件挂载时,将调用此fetch方法,以便将列表设置为状态,并传递给视图中的MediaList组件。

mern-mediastream/client/core/Home.js

componentDidMount = () => {
    listPopular().then((data) => {
      if (data.error) {
        console.log(data.error) 
      } else {
        this.setState({media: data}) 
      }
    })
  }

在主页视图中,我们将添加MediaList如下,列表作为 prop 提供:

<MediaList media={this.state.media}/>

按用户列出媒体

为了检索特定用户上传的媒体列表,我们将设置一个 API,该 API 在路由上接受'/api/media/by/:userId'的 GET 请求。

mern-mediastream/server/routes/media.routes.js

router.route('/api/media/by/:userId')
         .get(mediaCtrl.listByUser) 

listByUser控制器方法将查询媒体集合,以查找postedBy值与userId匹配的媒体文档。

mern-mediastream/server/controllers/media.controller.js

const listByUser = (req, res) => {
  Media.find({postedBy: req.profile._id})
  .populate('postedBy', '_id name')
  .sort('-created')
  .exec((err, posts) => {
    if (err) {
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(posts)
  })
}

为了在前端视图中使用此用户列表 API,我们将在api-media.js中设置相应的fetch方法。

mern-mediastream/client/user/api-user.js

const listByUser = (params) => {
  return fetch('/api/media/by/'+ params.userId, {
    method: 'GET',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    }
  }).then(response => {
    return response.json() 
  }).catch((err) => console.log(err)) 
}

这个 fetch 方法可以在Profile组件中使用,类似于在主页视图中使用的listPopularfetch 方法,以检索列表数据,设置状态,然后传递给MediaList组件。

显示、更新和删除媒体

MERN Mediastream 的任何访问者都可以查看媒体详细信息并流式传输视频,而只有注册用户才能在在应用程序上发布后随时编辑详细信息和删除媒体。

显示媒体

MERN Mediastream 的任何访问者都可以浏览到单个媒体视图,播放视频并阅读与媒体相关的详细信息。每次在应用程序上加载特定视频时,我们还将增加与媒体相关的观看次数。

阅读媒体 API

为了获取特定媒体记录的媒体信息,我们将设置一个路由,接受'/api/media/:mediaId'的 GET 请求。

mern-mediastream/server/routes/media.routes.js

router.route('/api/media/:mediaId')
    .get( mediaCtrl.incrementViews, mediaCtrl.read)

请求 URL 中的mediaId将导致执行mediaByID控制器方法,并将检索到的媒体文档附加到请求对象。然后,此媒体数据将由read控制器方法返回在响应中。

mern-mediastream/server/controllers/media.controller.js:

const read = (req, res) => {
  return res.json(req.media)
}

对此 API 的 GET 请求还将执行incrementViews控制器方法,该方法将找到匹配的媒体记录,并将views值增加 1,然后将更新后的记录保存到数据库中。

mern-mediastream/server/controllers/media.controller.js:

const incrementViews = (req, res, next) => {
  Media.findByIdAndUpdate(req.media._id, {$inc: {"views": 1}}, {new: true})
      .exec((err, result) => {
        if (err) {
          return res.status(400).json({
            error: errorHandler.getErrorMessage(err)
          })
        }
        next()
      })
}

为了在前端使用此读取 API,我们将在api-media.js中设置相应的 fetch 方法。

mern-mediastream/client/user/api-user.js:

const read = (params) => {
  return fetch(config.serverUrl+'/api/media/' + params.mediaId, {
    method: 'GET'
  }).then((response) => {
    return response.json()
  }).catch((err) => console.log(err))
}

读取 API 可用于在视图中呈现单个媒体详细信息,或者预填充媒体编辑表单。

媒体组件

Media组件将呈现单个媒体记录的详细信息,并在具有默认浏览器控件的基本ReactPlayer中流式传输视频。

Media组件可以调用读取 API 来获取媒体数据,也可以从调用读取 API 的父组件作为 prop 接收数据。在后一种情况下,父组件将添加Media组件,如下所示。

mern-mediastream/client/media/PlayMedia.js:

<Media media={this.state.media}/>

在 MERN Mediastream 中,我们在PlayMedia组件中添加了Media组件,该组件使用读取 API 从服务器获取媒体内容,并将其作为 prop 传递给 Media。 Media组件将获取这些数据并在视图中呈现它们,以显示详细信息并在ReactPlayer组件中加载视频。

标题,流派和观看次数可以在 Material-UICardHeader组件中呈现。

mern-mediastream/client/media/Media.js:

<CardHeader 
   title={this.props.media.title}
   action={<span>
                {this.props.media.views + ' views'}
           </span>}
   subheader={this.props.media.genre}
/>

视频 URL,基本上是我们在后端设置的 GET API 路由,将在ReactPlayer中加载,并具有默认的浏览器控件。

mern-mediastream/client/media/Media.js:

const mediaUrl = this.props.media._id
          ? `/api/media/video/${this.props.media._id}`
          : null
            … 
<ReactPlayer url={mediaUrl} 
             controls
             width={'inherit'}
             height={'inherit'}
             style={{maxHeight: '500px'}}
             config={{ attributes: 
                        { style: { height: '100%', width: '100%'} } 
}}/>

Media组件会渲染发布视频的用户的其他详细信息,媒体描述以及媒体创建日期。

mern-mediastream/client/media/Media.js:

<ListItem>
    <ListItemAvatar>
      <Avatar>
        {this.props.media.postedBy.name && 
                        this.props.media.postedBy.name[0]}
      </Avatar>
    </ListItemAvatar>
    <ListItemText primary={this.props.media.postedBy.name} 
              secondary={"Published on " + 
                        (new Date(this.props.media.created))
                        .toDateString()}/>
</ListItem>
<ListItem>
    <ListItemText primary={this.props.media.description}/>
</ListItem>

如果当前登录的用户也是发布显示的媒体的用户,则Media组件还会有条件地显示编辑和删除选项。

mern-mediastream/client/media/Media.js:

{(auth.isAuthenticated().user && auth.isAuthenticated().user._id) 
    == this.props.media.postedBy._id && (<ListItemSecondaryAction>
        <Link to={"/media/edit/" + this.props.media._id}>
          <IconButton aria-label="Edit" color="secondary">
            <Edit/>
          </IconButton>
        </Link>
        <DeleteMedia mediaId={this.props.media._id} mediaTitle=
       {this.props.media.title}/>
      </ListItemSecondaryAction>)}

编辑选项链接到媒体编辑表单,删除选项打开一个对话框,可以启动从数据库中删除特定媒体文档。

更新媒体详细信息

注册用户将可以访问其每个媒体上传的编辑表单,更新并提交此表单将保存更改到媒体集合中的文档中。

媒体更新 API

为了允许用户更新媒体详细信息,我们将设置一个媒体更新 API,该 API 将在'/api/media/:mediaId'处接受 PUT 请求,并在请求正文中包含更新的详细信息。

mern-mediastream/server/routes/media.routes.js

router.route('/api/media/:mediaId')
        .put(authCtrl.requireSignin, 
                mediaCtrl.isPoster, 
                    mediaCtrl.update)

当收到此请求时,服务器将首先通过调用isPoster控制器方法来确保登录用户是媒体内容的原始发布者。

mern-mediastream/server/controllers/media.controller.js

const isPoster = (req, res, next) => {
  let isPoster = req.media && req.auth 
  && req.media.postedBy._id == req.auth._id
  if(!isPoster){
    return res.status('403').json({
      error: "User is not authorized"
    })
  }
  next()
}

如果用户被授权,将调用update控制器方法next,以更新现有的媒体文档并将其保存到数据库中。

mern-mediastream/server/controllers/media.controller.js

const update = (req, res, next) => {
  let media = req.media
  media = _.extend(media, req.body)
  media.updated = Date.now()
  media.save((err) => {
    if (err) {
      return res.status(400).send({
        error: errorHandler.getErrorMessage(err)
      })
    }
    res.json(media)
  })
}

为了在前端访问更新 API,我们将在api-media.js中添加相应的获取方法,该方法将以必要的凭据和媒体详细信息作为参数。

mern-mediastream/client/user/api-user.js

const update = (params, credentials, media) => {
  return fetch('/api/media/' + params.mediaId, {
    method: 'PUT',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    },
    body: JSON.stringify(media)
  }).then((response) => {
    return response.json() 
  }).catch((err) => {
    console.log(err) 
  }) 
}

当用户更新并提交表单时,此获取方法将用于媒体编辑表单。

媒体编辑表单

媒体编辑表单将类似于新媒体表单,但不包括上传选项,并且字段将预填充现有细节:

包含此表单的EditMedia组件只能由登录用户访问,并将呈现在'/media/edit/:mediaId'。此私有路由将在MainRouter中与其他前端路由一起声明。

mern-mediastream/client/MainRouter.js

<PrivateRoute path="/media/edit/:mediaId" component={EditMedia}/>

一旦EditMedia组件挂载到视图上,将调用获取调用以从读取媒体 API 检索媒体详细信息并设置为状态,以便在文本字段中呈现值。

mern-mediastream/client/media/EditMedia.js

  componentDidMount = () => {
    read({mediaId: this.match.params.mediaId}).then((data) => {
      if (data.error) {
        this.setState({error: data.error}) 
      } else {
        this.setState({media: data}) 
      }
    }) 
  }

表单字段元素将与NewMedia组件中的相同。当用户更新表单中的任何值时,将通过调用handleChange方法在状态中注册media对象中的更改。

mediastream/client/media/EditMedia.js

handleChange = name => event => {
    let updatedMedia = this.state.media
    updatedMedia[name] = event.target.value
    this.setState({media: updatedMedia})
}

当用户完成编辑并点击提交时,将调用更新 API,并提供所需的凭据和更改后的媒体值。

mediastream/client/media/EditMedia.js:

  clickSubmit = () => {
    const jwt = auth.isAuthenticated() 
    update({
      mediaId: this.state.media._id
    }, {
      t: jwt.token
    }, this.state.media).then((data) => {
      if (data.error) {
        this.setState({error: data.error}) 
      } else {
        this.setState({error: '', redirect: true, media: data}) 
      }
    }) 
}

这将更新媒体详情,并且与媒体相关的视频文件将保持在数据库中不变。

删除媒体

经过身份验证的用户可以完全删除他们上传到应用程序的媒体,包括媒体集合中的媒体文档,以及使用 GridFS 存储在 MongoDB 中的文件块。

删除媒体 API

在后端,我们将添加一个 DELETE 路由,允许授权用户删除他们上传的媒体记录。

mern-mediastream/server/routes/media.routes.js:

router.route('/api/media/:mediaId')
        .delete(authCtrl.requireSignin, 
                    mediaCtrl.isPoster, 
                        mediaCtrl.remove)

当服务器在'/api/media/:mediaId'接收到 DELETE 请求时,它将首先确保登录用户是需要删除的媒体的原始发布者。然后remove控制器方法将从数据库中删除指定的媒体详情。

mern-mediastream/server/controllers/media.controller.js:

const remove = (req, res, next) => {
  let media = req.media
    media.remove((err, deletedMedia) => {
      if (err) {
        return res.status(400).json({
          error: errorHandler.getErrorMessage(err)
        })
      }
      gridfs.remove({ _id: req.media._id })
      res.json(deletedMedia)
    })
}

除了从媒体集合中删除媒体记录外,我们还使用gridfs来删除数据库中存储的相关文件详情和块。

我们还将在api-media.js中添加一个相应的方法来从视图中获取delete API。

mern-mediastream/client/user/api-user.js:

const remove = (params, credentials) => {
  return fetch('/api/media/' + params.mediaId, {
    method: 'DELETE',
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + credentials.t
    }
  }).then((response) => {
    return response.json() 
  }).catch((err) => {
    console.log(err) 
  }) 
}

删除媒体组件

DeleteMedia组件被添加到Media组件中,只对添加了特定媒体的已登录用户可见。该组件以媒体 ID 和标题作为 props:

这个DeleteMedia组件基本上是一个图标按钮,点击后会打开一个确认对话框,询问用户是否确定要删除他们的视频。

mern-mediastream/client/media/DeleteMedia.js:

<IconButton aria-label="Delete" onClick={this.clickButton} color="secondary">
    <DeleteIcon/>
</IconButton>
<Dialog open={this.state.open} onClose={this.handleRequestClose}>
  <DialogTitle>{"Delete "+this.props.mediaTitle}</DialogTitle>
  <DialogContent>
     <DialogContentText>
         Confirm to delete {this.props.mediaTitle} from your account.
     </DialogContentText>
  </DialogContent>
  <DialogActions>
     <Button onClick={this.handleRequestClose} color="primary">
        Cancel
     </Button>
     <Button onClick={this.deleteMedia} 
              color="secondary" 
              autoFocus="autoFocus"
              variant="raised">
        Confirm
     </Button>
  </DialogActions>
</Dialog>

当用户确认删除意图时,将调用delete获取方法。

mern-mediastream/client/media/DeleteMedia.js:

deleteMedia = () => {
    const jwt = auth.isAuthenticated() 
    remove({
      mediaId: this.props.mediaId
    }, {t: jwt.token}).then((data) => {
      if (data.error) {
        console.log(data.error) 
      } else {
        this.setState({redirect: true}) 
      }
    }) 
}

然后在成功删除后,用户将被重定向到主页。

mern-mediastream/client/media/DeleteMedia.js:

if (this.state.redirect) {
   return <Redirect to='/'/> 
}

本章开发的 MERN Mediastream 应用程序是一个完整的媒体流应用程序,具有将视频文件上传到数据库的功能,将存储的视频流回给观众的功能,支持 CRUD 操作,如媒体创建、更新、读取和删除,以及按上传者或受欢迎程度列出媒体的选项。

总结

在本章中,我们通过扩展 MERN 骨架应用程序并利用 MongoDB GridFS 开发了一个媒体流应用程序。

除了为媒体添加基本的添加、更新、删除和列表功能外,我们还研究了基于 MERN 的应用如何允许用户上传视频文件,将这些文件存储到 MongoDB GridFS 中,并根据需要部分或完全地向观看者流式传输视频。我们还介绍了使用默认浏览器控件来流式传输视频文件的ReactPlayer的基本用法。

在下一章中,我们将看到如何使用自定义控件和功能定制ReactPlayer,以便用户拥有更多选项,比如播放列表中的下一个视频。此外,我们将讨论如何通过实现带有媒体视图数据的服务器端渲染来改善媒体详情的搜索引擎优化。