Skip to content

大家好,我是村长!

本节我们实现支付业务,支付流程实际上就是订单状态的变化过程,下面我们先看一下详细流程。

支付流程

从用户点击“订阅”按钮开始,支付流程就开启了:

  1. 首先创建订单(状态为等待确认 WAIT_CONFIRM);

  2. 然后跳转订单确认页面;

  3. 用户确认订单(状态变为等待支付 WAIT_PAY);

  4. 并跳转支付页面;

  5. 用户扫码支付结束(订单状态变为已完成 COMPLETED);

  6. 页面发现订单完成跳转至课程详情页。

页面设计

下面是流程中两个关键页面的草图,分别是:

订单确认页,order-confirm.vue。

支付页,order-pay.vue。

接口实现

我们需要实现两个接口:

typescript
   /order:创建订单。
     method: post
     body:{ courseId: number }
     返回 { ok: boolean, data: { orderId: number } }

   /order/[id]:获取订单详情。
     method:get
     返回 { ok: boolean, data: Order }

   /order: 更新订单状态。
     method:patch
     body: { id: number, status: string }
     返回 { ok: boolean }

创建 orderRepository,server/database/repositories/orderRepository.ts

typescript
    import type { Order } from '@prisma/client'
    import prisma from '~/server/database/client'
    
    export async function createOrder(data: Order) {
      const user = await prisma.order.create({ data })
      return user
    }
    
    export async function getOrderById(id: number) {
      const result = await prisma.order.findUnique({
        where: {
          id,
        },
        include: {
          course: {
            select: {
              title: true,
              cover: true,
              price: true,
            },
          },
        },
      })
      return result
    }
    
    export async function updateOrder(id: number, data: Partial<Order>) {
      const result = await prisma.order.update({
        where: {
          id,
        },
        data,
      })
      return result
    }

分别实现 3 个接口:

  • 创建订单,server/api/order.post.ts

  • 更新订单,server/api/order.patch.ts

  • 订单详情,server/api/order/[id].ts

server/api/order.post.ts

typescript
    import type { Order } from '@prisma/client'
    import { OrderStatus } from '@prisma/client'
    import { isNuxtError } from 'nuxt/app'
    import { getTokenInfo } from '../database/service/token'
    import { createOrder } from '../database/repositories/orderRepositor'
    
    export default defineEventHandler(async (e) => {
      const { courseId } = await readBody(e)
    
      const result = getTokenInfo(e)
      if (isNuxtError(result))
        return sendError(e, result)
    
      const order = {
        courseId,
        userId: result.id,
        createdAt: new Date(),
        status: OrderStatus.WAIT_CONFIRM,
      } as Order
    
      const o = await createOrder(order)
    
      return { ok: true, data: { orderId: o.id } }
    })

更新订单,server/api/order.patch.ts

typescript
    import { updateOrder } from '../database/repositories/orderRepositor'
    
    export default defineEventHandler(async (e) => {
      const body = await readBody(e)
    
      try {
        await updateOrder(body.id, { status: body.status })
        return { ok: true }
      }
      catch (error) {
        return sendError(e, createError('订单状态更新失败'))
      }
    })

订单详情,server/api/order/[id].ts

typescript
    import { getOrderById } from '~/server/database/repositories/orderRepositor'
    
    export default defineEventHandler(async (e) => {
      const id = e.context.params?.id ? parseInt(e.context.params.id) : undefined
      if (!id) {
        return sendError(e, createError({
          statusCode: 400,
          statusMessage: '缺少订单id',
        }))
      }
      const order = await getOrderById(id)
      return { ok: true, data: order }
    })

前端页面实现

前端需要新增两个页面:

  • 订单确认页面;

  • 支付页面。

第一个变化是详情页中点击“订阅”按钮的操作,我们首先创建订单,成功之后跳转确认页并传入 orderId

[id].vue

typescript
    const subscribe = async () => {
      // 创建订单
      const { ok, data } = await httpPost<IResult>('/api/order', { courseId: id })
    
      if (ok) {
        // 然后跳转订单确认页面
        navigateTo(`/order-confirm/?id=${data.orderId}`)
      }
    }
``

接下来实现订单确认,我们获取订单详情和关联的课程内容,order-confirm.vue

```vue
    <script setup lang="ts">
    import type { IResult } from '../types/IResult'
    
    const route = useRoute()
    const id = route.query.id
    const { data } = await httpGet<IResult>(`/api/order/${id}`)
    const course = computed(() => data.course)
    // 创建订单
    const confirmOrder = async () => {
      navigateTo(`/order-pay/?id=${id}`)
    }
    </script>
    
    <template>
      <NCard class="font-semibold mb-3 text-gray-500" size="medium">
        <h2>产品信息</h2>
        <div class="flex mb-5">
          <NImage
            :src="`/${course.cover}`" object-fit="cover"
            class="rounded w-[380px] h-[210px] mr-5"
          />
          <div class="flex flex-1 ml-4 flex-col">
            <h5 class="flex text-xl text-gray-600 ">
              {{ course.title }}
            </h5>
            <p class="my-2 text-sm text-gray-400 ml-[0.1rem]">
              {{ course.desc }}
            </p>
          </div>
        </div>
    
        <div class="flex items-center mb-5">
          <span class="text-red-400 text-xl">请在 30 分钟内完成支付</span>
        </div>
    
        <div class="flex justify-end items-center">
          总计:
          <span class="text-green-600 font-bold pr-4 text-2xl">
            🌱 {{ course.price }}
          </span>
          <NButton type="primary" @click="confirmOrder">
            确认订单
          </NButton>
        </div>
      </NCard>
    </template>

最后是支付页,这里刻意简化了支付流程,实际上二维码是后端生成的支付链接生成的,同时前端页面应该轮询支付状态,待成功之后跳转页面。order-pay.vue:

vue
    <script setup lang="ts">
    import type { Course } from '.prisma/client'
    import type { IResult } from '../types/IResult'
    
    const route = useRoute()
    const { id } = route.query
    
    // 获取订单过期时间,用于倒计时,如果已过期可以设置isTimeout,略
    const { data } = await httpGet<IResult>(`/api/order/${id}`)
    const course = computed<Course | undefined>(() => data?.course)
    
    if (process.client) {
      setTimeout(async () => {
        const { ok } = await httpPost<IResult>('/api/ordercomplete', { id, status: 'COMPLETED' })
    
        if (ok)
          navigateTo(`/course/detail/${course.value!.id}`)
      }, 5000)
    }
    
    const isTimeout = ref(false)
    </script>
    
    <template>
      <div class="flex justify-center">
        <NCard class="w-[450px] mb-10 text-center">
          <h1 class="text-3xl mb-2 text-center">
            确认支付
          </h1>
          <p class="flex  text-gray-500 justify-center">
            <span v-if="!isTimeout">
              距离过期还有&nbsp;
              <Counter :expire="30" class="text-rose-500" @end="isTimeout = true" />
            </span>
            <span v-else>订单已过期,请重新订阅课程</span>
          </p>
          <h5 class="flex justify-center items-center mt-0 mb-3">
            总额:
            <span class="text-green-600 font-bold text-2xl">
              🌱 {{ course?.price }}
            </span>
          </h5>
          <img src="/qrcode.jpg">
          <div class="flex justify-center items-center py-4 text-green-500">
            <div class="ml-3 text-gray-500 text-sm">
              <p>请用防狼枪扫射二维码</p>
            </div>
          </div>
        </NCard>
      </div>
    </template>

这个页面用到一个倒计时器,Counter.vue:

vue
    <script setup lang="ts">
    const props = defineProps({
      expires: {
        type: Number,
        default: 30,
      },
    })
    
    const emit = defineEmits(['end'])
    
    const transform = (data: number) => {
      const minutes = Math.floor((data % (60 * 60)) / 60)
      const seconds = data % 60
      return `${minutes < 10 ? (`0${minutes}`) : minutes}:${seconds < 10 ? (`0${seconds}`) : seconds}`
    }
    
    let timer: any
    const timeout = ref(0)
    
    onMounted(() => init())
    onBeforeUnmount(() => clearInterval(timer!))
    
    // 初始化倒计时
    function init() {
      if (timer)
        clearInterval(timer)
    
      timeout.value = props.expires * 60
      if (timeout.value > 0)
        timer = setInterval(handleTimeout, 1000)
    }
    
    function handleTimeout() {
      if (timeout.value === 0) {
        emit('end')
        return clearInterval(timer)
      }
      timeout.value--
    }
    </script>
    
    <template>
      <div class="text-center">
        {{ transform(timeout) }}
      </div>
    </template>

完成!最终效果如下:

下次预告

接下来,我们就剩下用户中心的开发工作了。

Released under the MIT License.