2026年的本地生活服务赛道,餐饮数字化已经从“可选配置”变成了商家的“刚需基建”。对于中小餐饮品牌、县域创业者而言,搭建一套同时覆盖堂食点餐、外卖配送的小程序系统,不再需要投入过多来建多端开发团队——基于最新的UniApp跨端技术栈,搭配Nginx高性能反向代理架构,仅两周就能完成从0到1的全流程落地。

技术栈说明

  • 源码与演示:c.ymzan.top
  • 前端(用户端小程序+H5):UniApp 3 × Vue 3 × Vite × TypeScript × Pinia × uView Plus
  • 商家管理后台:Vue 3 + Element Plus(简略提及)
  • 后端 API:NestJS 10+ × TypeORM × MySQL 8.0 × Redis 7
  • 部署:Docker / docker-compose + Nginx 1.25+ 反向代理 + HTTPS(Let’s Encrypt)
  • 第三方:微信小程序登录 / 微信 JSAPI / 腾讯地图 LBS

系统架构总览

                    ┌──────────────────────┐
                    │   微信小程序 / H5     │ ← UniApp 编译
                    └──────────┬───────────┘
                               │ HTTPS
                    ┌──────────▼───────────┐
                    │   Nginx (80/443)      │
                    │  ┌──────────────────┐ │
                    │  │ /api/* → NestJS  │ │  ← 反向代理
                    │  │ /admin → Vue SPA │ │
                    │  │ /upload → 静态   │ │
                    │  └──────────────────┘ │
                    └──────────┬───────────┘
              ┌────────────────┼────────────────┐
       ┌──────▼──────┐  ┌─────▼─────┐  ┌──────▼──────┐
       │  NestJS API │  │ MySQL 8.0 │  │ Redis 7.0   │
       │  :3000      │  │ 主从可选  │  │ 缓存/锁/Session│
       └─────────────┘  └───────────┘  └─────────────┘

数据库设计(核心表)

-- 字符集统一 utf8mb4
CREATE DATABASE food_order
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_unicode_ci;

USE food_order;

-- 菜品分类
CREATE TABLE category (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(30) NOT NULL,
  sort INT DEFAULT 0,
  enabled TINYINT(1) DEFAULT 1
);

-- 菜品
CREATE TABLE dish (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  category_id INT NOT NULL,
  name VARCHAR(50) NOT NULL,
  price DECIMAL(10,2) NOT NULL,
  image VARCHAR(255),
  description VARCHAR(200),
  stock INT DEFAULT 999,
  enabled TINYINT(1) DEFAULT 1,
  INDEX idx_category (category_id)
);

-- 用户
CREATE TABLE `user` (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  openid VARCHAR(64) UNIQUE,
  phone VARCHAR(20),
  nickname VARCHAR(50),
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

-- 订单
CREATE TABLE `order` (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_no VARCHAR(32) UNIQUE NOT NULL,
  user_id BIGINT NOT NULL,
  total_amount DECIMAL(10,2) NOT NULL,
  status TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2配送中 3完成 4取消',
  address JSON,
  pay_type TINYINT DEFAULT 1 COMMENT '1微信 2余额',
  paid_at DATETIME,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_user (user_id),
  INDEX idx_status (status)
);

-- 订单明细
CREATE TABLE order_item (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  order_id BIGINT NOT NULL,
  dish_id BIGINT NOT NULL,
  dish_name VARCHAR(50),
  price DECIMAL(10,2),
  quantity INT,
  INDEX idx_order (order_id)
);

后端 —— NestJS 10 + TypeORM + Redis

1. 初始化项目

npm i -g @nestjs/cli
nest new food-api
cd food-api
npm i @nestjs/typeorm typeorm mysql2 redis @nestjs/config
npm i class-validator class-transformer

2. 全局配置(src/app.module.ts 简化示例)

// src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from './user/user.module';
import { DishModule } from './dish/dish.module';
import { OrderModule } from './order/order.module';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (c: ConfigService) => ({
        type: 'mysql',
        host: c.get('DB_HOST'),
        port: 3306,
        username: c.get('DB_USER'),
        password: c.get('DB_PASS'),
        database: c.get('DB_NAME'),
        entities: [__dirname + '/**/*.entity{.ts,.js}'],
        synchronize: false,
        logging: false,
      }),
    }),
    UserModule,
    DishModule,
    OrderModule,
  ],
})
export class AppModule {}

.env

DB_HOST=127.0.0.1
DB_USER=root
DB_PASS=123456
DB_NAME=food_order
JWT_SECRET=food_order_secret_2026
WECHAT_APPID=wxxxxxxxxx
WECHAT_SECRET=xxxxxxxx

3. 菜品模块(示例 Entity + Controller)

// src/dish/dish.entity.ts
import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm';

@Entity('dish')
export class Dish {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  categoryId: number;

  @Column()
  name: string;

  @Column('decimal', { precision: 10, scale: 2 })
  price: number;

  @Column({ nullable: true })
  image: string;

  @Column({ default: 1 })
  enabled: boolean;
}
// src/dish/dish.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { DishService } from './dish.service';

@Controller('api/dishes')
export class DishController {
  constructor(private readonly svc: DishService) {}

  @Get()
  list(@Query('categoryId') categoryId?: string) {
    return this.svc.findByCategory(+categoryId);
  }
}

4. 全局前缀 & CORS & ValidationPipe(main.ts)

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.setGlobalPrefix('api');
  app.enableCors();
  app.useGlobalPipes(
    new ValidationPipe({ whitelist: true, transform: true }),
  );
  await app.listen(3000);
}
bootstrap();

UniApp 前端(Vue 3 + Vite + TypeScript + Pinia)

1. 创建项目

使用 HBuilderX「新建 UniApp Vue3+TS 模板」或命令行:

# 若已安装 @dcloudio/uvue-cli
npx degit dcloudio/uni-preset-vue#vite-ts food-miniapp
cd food-miniapp
pnpm install
pnpm add pinia @uni-helper/pinia-plugin-uni pinia-plugin-persistedstate
pnpm add unplugin-auto-import -D

2. Pinia 配置(src/stores/cart.ts — 购物车持久化)

// src/stores/cart.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useCartStore = defineStore('cart', () => {
  const items = ref<Array<{
    dishId: number;
    name: string;
    price: number;
    qty: number;
  }>>([]);

  const totalPrice = computed(() =>
    items.value.reduce((s, i) => s + i.price * i.qty, 0),
  );

  function add(dish: { dishId: number; name: string; price: number }) {
    const exist = items.value.find(i => i.dishId === dish.dishId);
    exist ? exist.qty++ : items.value.push({ ...dish, qty: 1 });
  }

  function clear() { items.value = []; }

  return { items, totalPrice, add, clear };
}, {
  persist: { storage: { getItem: uni.getStorageSync, setItem: uni.setStorageSync } },
});

3. HTTP 请求封装(src/utils/request.ts)

// src/utils/request.ts
const BASE_URL = import.meta.env.VITE_API_BASE as string;

export function request<T>(options: {
  url: string;
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
  data?: any;
  header?: Record<string, string>;
}): Promise<T> {
  return new Promise((resolve, reject) => {
    uni.request({
      url: BASE_URL + options.url,
      method: options.method ?? 'GET',
      data: options.data,
      header: {
        'Content-Type': 'application/json',
        Authorization: uni.getStorageSync('token')
          ? 'Bearer ' + uni.getStorageSync('token')
          : '',
        ...options.header,
      },
      success: res => {
        if (res.statusCode === 200) resolve(res.data as T);
        else reject(res);
      },
      fail: reject,
    });
  });
}

.env.development

VITE_API_BASE=http://localhost:3000

.env.production

VITE_API_BASE=https://api.yourdomain.com

4. 菜品列表页(pages/menu/menu.vue 简化)

<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { request } from '@/utils/request';
import { useCartStore } from '@/stores/cart';

const dishes = ref<any[]>([]);
const cart = useCartStore();

onMounted(async () => {
  dishes.value = await request<any[]>('/api/dishes?categoryId=1');
});

function handleAdd(d: any) {
  cart.add({ dishId: d.id, name: d.name, price: d.price });
}
</script>

<template>
  <view class="menu">
    <view v-for="d in dishes" :key="d.id" class="card">
      <image :src="d.image" mode="aspectFill" />
      <text>{{ d.name }}</text>
      <text class="price">¥{{ d.price }}</text>
      <button size="mini" @click="handleAdd(d)">加购</button>
    </view>
  </view>
</template>

5. 微信小程序登录(获取 openid)

// src/utils/wx-auth.ts
import { request } from './request';

export function wxLogin() {
  uni.login({
    provider: 'weixin',
    success: async (res) => {
      // 调后端 code2session 接口
      const data = await request<{ token: string; openid: string }>({
        url: '/api/auth/wx-login',
        method: 'POST',
        data: { code: res.code },
      });
      uni.setStorageSync('token', data.token);
    },
  });
}

后端 NestJS 伪代码(AuthService):

async wxLogin(code: string) {
  const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${this.appid}&secret=${this.secret}&js_code=${code}&grant_type=authorization_code`;
  const res = await firstValueFrom(this.http.get(url));
  // 查找/创建用户 → 签发 JWT
  return { token: this.jwtService.sign({ openid: res.openid }) };
}

多端编译说明

# H5
pnpm run dev:h5        # 本地 http://localhost:5173
pnpm run build:h5      # 产出 dist/build/h5/

# 微信小程序(需用 HBuilderX 或 CLI 导入微信开发者工具)
pnpm run dev:mp-weixin # 产出 dist/dev/mp-weixin/
pnpm run build:mp-weixin

UniApp 条件编译示例(区分微信小程序与 H5 支付):

<!-- #ifdef MP-WEIXIN -->
<button @click="wxPay">微信支付</button>
<!-- #endif -->
<!-- #ifdef H5 -->
<button @click="h5Pay">H5 支付</button>
<!-- #endif -->

Docker 容器化

docker-compose.yml

version: '3.9'
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: 123456
      MYSQL_DATABASE: food_order
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  api:
    build: ./food-api
    ports:
      - "3000:3000"
    env_file:
      - ./food-api/.env
    depends_on:
      - mysql
      - redis

  nginx:
    image: nginx:1.25-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./dist/h5:/usr/share/nginx/html/h5
      - ./ssl:/etc/nginx/ssl
    depends_on:
      - api

volumes:
  mysql_data:

NestJS Dockerfile(多阶段构建):

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.env ./
CMD ["node", "dist/main"]

Nginx 反向代理 + HTTPS 配置

nginx/conf.d/food.conf

server {
    listen 80;
    server_name api.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.yourdomain.com;

    ssl_certificate     /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    # ===== API 反向代理到 NestJS =====
    location /api/ {
        proxy_pass         http://api:3000/api/;
        proxy_http_version 1.1;
        proxy_set_header   Host $host;
        proxy_set_header   X-Real-IP $remote_addr;
        proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;
        # 支持 WebSocket(订单推送等)
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
    }

    # ===== H5 静态资源(UniApp build:h5 产物)=====
    location /h5/ {
        alias /usr/share/nginx/html/h5/;
        try_files $uri $uri/ /h5/index.html;
    }

    # ===== 上传静态资源 =====
    location /upload/ {
        alias /data/uploads/;
    }

    # 安全头
    add_header Strict-Transport-Security "max-age=31536000" always;
}

验证并重载:

docker exec -it food-nginx nginx -t
docker exec -it food-nginx nginx -s reload

八、微信支付 JSAPI 简要流程

  1. 小程序端调 wx.login() 拿 code → 后端换取 openid
  2. 下单时后端调微信统一下单接口(/pay/unifiedorder),传入 trade_type=JSAPIopenid
  3. 后端返回 timeStamp / nonceStr / package / signType / paySign
  4. 小程序调 wx.requestPayment(params) 拉起支付
  5. 微信异步回调 /api/pay/notify → 验签 → 更新订单状态

⚠️ notify 接口需在 Nginx 中放行 CSRF 校验,且返回 <xml><return_code><![CDATA[SUCCESS]]></return_code></xml>

在这里插入图片描述

小结

UniApp(Vue3+Vite+TS) + NestJS + MySQL + Redis + Docker + Nginx 反向代理从零搭建餐饮点餐与外卖小程序的落地,同时覆盖、Android App、iOS App、H5等多个平台,开发成本比传统模式降低70%。后续我们还可以基于这套架构持续优化:通过CDN加速静态资源访问,将用户首屏加载时间进一步压缩到1秒以内;接入Redis缓存热点菜品数据,应对午晚高峰的高并发请求;基于UniApp X的UTS能力开发原生推送插件,实现订单状态实时推送提醒。

对于中小餐饮创业者而言,这套方案完全可以支撑从单店到县域连锁的业务发展,无需投入高昂的开发成本,就能拥有一套完全自主可控的数字化点餐系统。

Logo

一站式 AI 云服务平台

更多推荐