外卖点餐连锁店餐饮生鲜奶茶外卖店内扫码点餐源码同城外卖校园外卖源码的扫码逻辑
📱 扫码点餐系统 - 完整扫码逻辑 + 源码示例
外卖点餐 | 连锁店 | 餐饮生鲜 | 奶茶 | 店内扫码点餐 | 同城外卖 | 校园外卖
🎯 扫码业务场景总览
| 场景 | 扫码后行为 | 核心逻辑 |
|---|---|---|
| 🍽️ 店内扫码点餐 | 进入店铺菜单页 | 识别店铺ID → 加载菜单 |
| 🏃 外卖下单 | 进入外卖店铺页 | 识别店铺+位置 → 计算配送费 |
| 🎁 营销活动 | 领券/红包页 | 识别活动ID → 发放优惠券 |
| 👤 员工登录 | 员工管理后台 | 识别员工ID → 权限验证 |
| 📦 自取码 | 取餐码页面 | 识别订单ID → 显示取餐码 |
🔧 一、后端扫码逻辑 (Java Spring Boot)
1️⃣ 扫码码生成规则
java
/** * 二维码内容格式: * 店内点餐: CAMPUS://SHOP?id=1001&table=A05 * 外卖下单: CAMPUS://DELIVERY?shopId=1001&address=宿舍3号楼 * 营销活动: CAMPUS://COUPON?id=2001&type=1 * 取餐码: CAMPUS://PICKUP?orderNo=TX20240101001 */ @Service public class QRCodeService { @Autowired private ShopMapper shopMapper; /** * 生成店内点餐二维码 */ public String generateShopQRCode(Long shopId, String tableNo) { // 格式: CAMPUS://SHOP?id=1001&table=A05 String content = String.format("CAMPUS://SHOP?id=%d&table=%s", shopId, tableNo); // 使用ZXing生成二维码图片(Base64) return QRCodeUtil.generateBase64(content, 300, 300); } /** * 生成外卖店铺二维码 */ public String generateDeliveryQRCode(Long shopId, String address) { String content = String.format("CAMPUS://DELIVERY?shopId=%d&address=%s", shopId, URLEncoder.encode(address, StandardCharsets.UTF_8)); return QRCodeUtil.generateBase64(content, 300, 300); } /** * 生成营销活动二维码 */ public String generateCouponQRCode(Long activityId, Integer type) { String content = String.format("CAMPUS://COUPON?id=%d&type=%d", activityId, type); return QRCodeUtil.generateBase64(content, 300, 300); } /** * 生成取餐码二维码 */ public String generatePickupQRCode(String orderNo) { String content = String.format("CAMPUS://PICKUP?orderNo=%s", orderNo); return QRCodeUtil.generateBase64(content, 300, 300); } }2️⃣ 扫码解析Controller ⭐⭐⭐
java
@RestController @RequestMapping("/api/scan") @CrossOrigin public class ScanController { @Autowired private ShopService shopService; @Autowired private CouponService couponService; @Autowired private OrderService orderService; /** * 🔑 核心:扫码后统一入口 * 前端传入: ?code=CAMPUS://SHOP?id=1001&table=A05 */ @GetMapping("/parse") public Result<?> parseQRCode(@RequestParam String code) { log.info("扫码内容: {}", code); // 1. 解析协议头 if (!code.startsWith("CAMPUS://")) { return Result.error("无效的二维码"); } String path = code.substring(11); // 去掉 CAMPUS:// String[] parts = path.split("\\?"); String type = parts[0]; // SHOP / DELIVERY / COUPON / PICKUP switch (type) { case "SHOP": return parseShopQRCode(parts[1]); case "DELIVERY": return parseDeliveryQRCode(parts[1]); case "COUPON": return parseCouponQRCode(parts[1]); case "PICKUP": return parsePickupQRCode(parts[1]); default: return Result.error("未知的二维码类型"); } } /** * 🍽️ 店内点餐二维码解析 */ private Result<?> parseShopQRCode(String params) { Map<String, String> paramMap = parseParams(params); Long shopId = Long.parseLong(paramMap.get("id")); String tableNo = paramMap.get("table"); // 1. 查询店铺信息 Shop shop = shopService.getById(shopId); if (shop == null) { return Result.error("店铺不存在"); } // 2. 查询店铺菜单 List<MenuItem> menuList = shopService.getMenuByShopId(shopId); // 3. 查询桌台信息(可选) TableInfo table = null; if (StringUtils.isNotBlank(tableNo)) { table = tableService.getByTableNo(shopId, tableNo); } // 4. 返回前端所需数据 Map<String, Object> data = new HashMap<>(); data.put("shop", shop); data.put("menu", menuList); data.put("table", table); data.put("scanTime", LocalDateTime.now()); // 记录扫码时间(用于统计) // 5. 记录扫码日志(用于数据分析) scanLogService.save(shopId, tableNo, "SHOP"); return Result.success(data); } /** * 🏃 外卖下单二维码解析 */ private Result<?> parseDeliveryQRCode(String params) { Map<String, String> paramMap = parseParams(params); Long shopId = Long.parseLong(paramMap.get("shopId")); String address = paramMap.get("address"); Shop shop = shopService.getById(shopId); if (shop == null) { return Result.error("店铺不存在"); } // 计算配送费 BigDecimal deliveryFee = calculateDeliveryFee(shopId, address); Map<String, Object> data = new HashMap<>(); data.put("shop", shop); data.put("deliveryFee", deliveryFee); data.put("address", address); data.put("isDelivery", true); // 标记为外卖模式 return Result.success(data); } /** * 🎁 营销活动二维码解析 */ private Result<?> parseCouponQRCode(String params) { Map<String, String> paramMap = parseParams(params); Long activityId = Long.parseLong(paramMap.get("id")); Integer type = Integer.parseInt(paramMap.get("type")); CouponActivity activity = couponService.getById(activityId); if (activity == null || activity.getStatus() != 1) { return Result.error("活动不存在或已结束"); } // 检查是否已领取 boolean alreadyReceived = couponService.hasReceived(activityId); Map<String, Object> data = new HashMap<>(); data.put("activity", activity); data.put("alreadyReceived", alreadyReceived); return Result.success(data); } /** * 📦 取餐码二维码解析 */ private Result<?> parsePickupQRCode(String params) { Map<String, String> paramMap = parseParams(params); String orderNo = paramMap.get("orderNo"); Order order = orderService.getByOrderNo(orderNo); if (order == null) { return Result.error("订单不存在"); } // 生成6位取餐码 String pickupCode = generatePickupCode(); order.setPickupCode(pickupCode); order.setStatus(3); // 已完成,待取餐 orderMapper.updateById(order); Map<String, Object> data = new HashMap<>(); data.put("order", order); data.put("pickupCode", pickupCode); return Result.success(data); } /** * 工具:解析URL参数 */ private Map<String, String> parseParams(String params) { Map<String, String> map = new HashMap<>(); String[] pairs = params.split("&"); for (String pair : pairs) { String[] kv = pair.split("="); if (kv.length == 2) { map.put(kv[0], kv[1]); } } return map; } /** * 计算配送费(根据距离) */ private BigDecimal calculateDeliveryFee(Long shopId, String address) { // 简化逻辑:3km内5元,每超1km加1元 Double distance = 2.5; // 实际应调用地图API计算 if (distance <= 3) { return new BigDecimal("5.00"); } else { int extraKm = (int) Math.ceil(distance - 3); return new BigDecimal("5.00").add(new BigDecimal(extraKm)); } } /** * 生成6位取餐码 */ private String generatePickupCode() { return String.format("%06d", new Random().nextInt(1000000)); } }3️⃣ 店铺菜单查询ShopService.java
java
@Service public class ShopServiceImpl implements ShopService { @Autowired private ShopMapper shopMapper; @Autowired private MenuMapper menuMapper; /** * 根据店铺ID查询菜单(店内点餐用) */ @Override public List<MenuItem> getMenuByShopId(Long shopId) { LambdaQueryWrapper<MenuItem> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(MenuItem::getShopId, shopId) .eq(MenuItem::getStatus, 1) // 上架 .orderByAsc(MenuItem::getSort); List<MenuItem> list = menuMapper.selectList(wrapper); // 按分类分组 Map<Long, List<MenuItem>> grouped = list.stream() .collect(Collectors.groupingBy(MenuItem::getCategoryId)); // 转换为前端需要的格式 List<CategoryVO> categories = grouped.entrySet().stream() .map(entry -> { CategoryVO vo = new CategoryVO(); vo.setCategoryId(entry.getKey()); vo.setItems(entry.getValue()); return vo; }) .collect(Collectors.toList()); return categories; } }📱 二、UniApp前端扫码逻辑 ⭐⭐⭐
1️⃣pages/scan/scan.vue- 扫码入口页
vue
<template> <view class="scan-page"> <!-- 顶部提示 --> <view class="header"> <text class="title">扫码点餐</text> <text class="subtitle">扫描桌上二维码,开始点餐</text> </view> <!-- 区域 --> <view class="scan-area" @click="startScan"> <camera v-if="!scanResult" class="camera" device-position="back" flash="off" @error="onCameraError"> <view class="scan-frame"> <view class="corner top-left"></view> <view class="corner top-right"></view> <view class="corner bottom-left"></view> <view class="corner bottom-right"></view> <text class="scan-tip">将二维码放入框内</text> </view> </camera> <!-- 扫码成功 --> <view v-if="scanResult" class="result-card"> <uni-icons type="checkbox-filled" size="60" color="#07c160"></uni-icons> <text class="result-text">扫码成功</text> <button class="btn-confirm" @click="goToPage">进入点餐</button> </view> </view> <!-- 备选方案:手动输入 --> <view class="manual-input"> <text class="divider">或者</text> <input v-model="manualCode" placeholder="输入桌号,如:A05" class="input" @confirm="manualSearch" /> <button class="btn-search" @click="manualSearch">查询</button> </view> </view> </template> <script> export default { data() { return { scanResult: null, manualCode: '', shopData: null } }, methods: { // 🔑 核心:调用微信扫码API startScan() { uni.scanCode({ onlyFromCamera: true, // 只从相机扫码 success: (res) => { console.log('扫码结果:', res.result) this.scanResult = res.result this.parseQRCode(res.result) }, fail: (err) => { console.error('扫码失败:', err) uni.showToast({ title: '扫码失败', icon: 'none' }) } }) }, // 解析二维码内容,请求后端 async parseQRCode(code) { uni.showLoading({ title: '解析中...' }) try { const res = await uni.request({ url: 'http://localhost:8080/api/scan/parse', method: 'GET', data: { code } }) if (res.data.code === 200) { this.shopData = res.data.data // 根据类型跳转不同页面 if (this.shopData.isDelivery) { // 外卖模式 uni.navigateTo({ url: `/pages/delivery/delivery?shopId=${this.shopData.shop.id}` }) } else { // 店内点餐模式 uni.navigateTo({ url: `/pages/order/order?shopId=${this.shopData.shop.id}&table=${this.shopData.table?.tableNo || ''}` }) } } else { uni.showToast({ title: res.data.message, icon: 'none' }) } } catch (e) { console.error(e) } finally { uni.hideLoading() } }, // 手动输入桌号查询 async manualSearch() { if (!this.manualCode) { return uni.showToast({ title: '请输入桌号', icon: 'none' }) } uni.showLoading({ title: '查询中...' }) try { const res = await uni.request({ url: 'http://localhost:8080/api/shop/getByTable', method: 'GET', data: { tableNo: this.manualCode } }) if (res.data.code === 200) { this.shopData = res.data.data uni.navigateTo({ url: `/pages/order/order?shopId=${this.shopData.shop.id}&table=${this.manualCode}` }) } } catch (e) { console.error(e) } finally { uni.hideLoading() } }, goToPage() { if (this.shopData.isDelivery) { uni.navigateTo({ url: `/pages/delivery/delivery?shopId=${this.shopData.shop.id}` }) } else { uni.navigateTo({ url: `/pages/order/order?shopId=${this.shopData.shop.id}` }) } }, onCameraError(e) { console.error('相机错误:', e) uni.showModal({ title: '提示', content: '无法打开相机,请检查权限设置', showCancel: false }) } } } </script> <style scoped> .scan-page { min-height: 100vh; background: #f5f5f5; padding: 40rpx; } .header { text-align: center; margin-bottom: 60rpx; } .title { font-size: 48rpx; font-weight: bold; color: #333; display: block; } .subtitle { font-size: 28rpx; color: #999; margin-top: 16rpx; } .scan-area { width: 100%; height: 600rpx; background: #000; border-radius: 20rpx; overflow: hidden; position: relative; } .camera { width: 100%; height: 100%; } .scan-frame { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 400rpx; height: 400rpx; border: 4rpx solid rgba(255,255,255,0.5); border-radius: 20rpx; } .corner { position: absolute; width: 60rpx; height: 60rpx; border-color: #07c160; border-style: solid; } .top-left { top: 0; left: 0; border-width: 6rpx 0 0 6rpx; border-radius: 10rpx 0 0 0; } .top-right { top: 0; right: 0; border-width: 6rpx 6rpx 0 0; border-radius: 0 10rpx 0 0; } .bottom-left { bottom: 0; left: 0; border-width: 0 0 6rpx 6rpx; border-radius: 0 0 0 10rpx; } .bottom-right { bottom: 0; right: 0; border-width: 0 6rpx 6rpx 0; border-radius: 0 0 10rpx 0; } .scan-tip { position: absolute; bottom: -60rpx; left: 50%; transform: translateX(-50%); color: #fff; font-size: 28rpx; white-space: nowrap; } .result-card { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(255,255,255,0.95); padding: 60rpx; border-radius: 20rpx; text-align: center; } .result-text { display: block; font-size: 32rpx; color: #333; margin: 20rpx 0 40rpx; } .btn-confirm { background: #07c160; color: #fff; border: none; border-radius: 50rpx; padding: 20rpx 80rpx; font-size: 32rpx; } .manual-input { margin-top: 60rpx; text-align: center; } .divider { color: #999; font-size: 28rpx; margin: 0 20rpx; } .input { display: inline-block; width: 300rpx; background: #fff; border-radius: 40rpx; padding: 16rpx 30rpx; font-size: 28rpx; } .btn-search { display: inline-block; margin-left: 20rpx; background: #07c160; color: #fff; border: none; border-radius: 40rpx; padding: 16rpx 40rpx; font-size: 28rpx; } </style>2️⃣pages/order/order.vue- 扫码后的点餐页 ⭐
vue
<template> <view class="order-page"> <!-- 店铺信息 --> <view class="shop-header"> <image :src="shop.coverImg" class="shop-cover" mode="aspectFill"></image> <view class="shop-info"> <text class="shop-name">{{ shop.name }}</text> <text class="table-info" v-if="table">桌号: {{ table }}</text> <text class="shop-desc">{{ shop.description }}</text> </view> </view> <!-- 菜单列表 --> <view class="menu-container"> <view v-for="category in menuList" :key="category.categoryId" class="category-section" :id="'cat-' + category.categoryId"> <text class="category-title">{{ category.categoryName }}</text> <view v-for="item in category.items" :key="item.id" class="menu-item" @click="showItemDetail(item)"> <image :src="item.image" class="item-img" mode="aspectFill"></image> <view class="item-info"> <text class="item-name">{{ item.name }}</text> <text class="item-desc">{{ item.description }}</text> <view class="item-bottom"> <text class="item-price">¥{{ item.price }}</text> <view class="cart-control" @click.stop="addToCart(item)"> <uni-icons v-if="getCartCount(item.id) > 0" type="checkbox-filled" size="20" color="#07c160"></uni-icons> <text v-if="getCartCount(item.id) > 0" class="count"> {{ getCartCount(item.id) }} </text> <uni-icons v-else type="plus-filled" size="20" color="#999"></uni-icons> </view> </view> </view> </view> </view> </view> <!-- 底部购物车栏 --> <view class="cart-bar" v-if="cartList.length > 0"> <view class="cart-info" @click="showCartPopup = true"> <view class="cart-icon-wrap"> <uni-icons type="cart-filled" size="32" color="#fff"></uni-icons> <text class="cart-badge">{{ totalCount }}</text> </view> <view class="cart-price"> <text class="total-label">合计:</text> <text class="total-amount">¥{{ totalPrice }}</text> </view> </view> <button class="btn-submit" @click="submitOrder">去结算</button> </view> <!-- 购物车弹窗 --> <uni-popup ref="cartPopup" type="bottom"> <view class="cart-popup"> <view class="popup-header"> <text class="popup-title">购物车</text> <text class="clear-btn" @click="clearCart">清空</text> </view> <scroll-view scroll-y class="cart-list"> <view v-for="item in cartList" :key="item.id" class="cart-item"> <text class="cart-item-name">{{ item.name }}</text> <view class="cart-item-control"> <view class="minus-btn" @click="removeFromCart(item.id)">-</view> <text class="cart-item-count">{{ item.count }}</text> <view class="plus-btn" @click="addToCart(item)">+</view> </view> <text class="cart-item-price">¥{{ (item.price * item.count).toFixed(2) }}</text> </view> </scroll-view> </view> </uni-popup> </view> </template> <script> export default { data() { return { shopId: null, table: '', shop: {}, menuList: [], cartList: [], // { id, name, price, count, image } showCartPopup: false, totalPrice: '0.00', totalCount: 0 } }, onLoad(options) { this.shopId = options.shopId this.table = options.table || '' this.loadShopData() }, methods: { async loadShopData() { uni.showLoading({ title: '加载中...' }) try { const res = await uni.request({ url: `http://localhost:8080/api/scan/parse`, method: 'GET', data: { code: `CAMPUS://SHOP?id=${this.shopId}&table=${this.table}` } }) if (res.data.code === 200) { this.shop = res.data.data.shop this.menuList = res.data.data.menu } } catch (e) { console.error(e) } finally { uni.hideLoading() } }, addToCart(item) { const exist = this.cartList.find(c => c.id === item.id) if (exist) { exist.count++ } else { this.cartList.push({ id: item.id, name: item.name, price: item.price, count: 1, image: item.image }) } this.calcTotal() }, removeFromCart(id) { const item = this.cartList.find(c => c.id === id) if (item) { item.count-- if (item.count <= 0) { this.cartList = this.cartList.filter(c => c.id !== id) } } this.calcTotal() }, getCartCount(id) { const item = this.cartList.find(c => c.id === id) return item ? item.count : 0 }, calcTotal() { this.totalCount = this.cartList.reduce((sum, item) => sum + item.count, 0) this.totalPrice = this.cartList.reduce( (sum, item) => sum + item.price * item.count, 0 ).toFixed(2) }, async submitOrder() { if (this.cartList.length === 0) return uni.showLoading({ title: '提交中...' }) try { const res = await uni.request({ url: 'http://localhost:8080/api/errand/publish', method: 'POST', header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') }, data: { shopId: this.shopId, tableNo: this.table, items: this.cartList.map(item => ({ menuId: item.id, count: item.count, price: item.price })), totalPrice: this.totalPrice } }) if (res.data.code === 200) { uni.showModal({ title: '下单成功', content: `订单号: ${res.data.data.orderNo}`, showCancel: false, success: () => { uni.redirectTo({ url: `/pages/orderDetail/orderDetail?orderNo=${res.data.data.orderNo}` }) } }) } } catch (e) { console.error(e) } finally { uni.hideLoading() } }, clearCart() { this.cartList = [] this.calcTotal() this.showCartPopup = false } } } </script> <style scoped> .order-page { padding-bottom: 140rpx; } .shop-header { background: #fff; padding: 30rpx; display: flex; gap: 20rpx; } .shop-cover { width: 160rpx; height: 160rpx; border-radius: 16rpx; } .shop-info { flex: 1; display: flex; flex-direction: column; justify-content: center; } .shop-name { font-size: 34rpx; font-weight: bold; color: #333; } .table-info { font-size: 24rpx; color: #07c160; margin-top: 8rpx; } .shop-desc { font-size: 24rpx; color: #999; margin-top: 8rpx; } .menu-container { background: #fff; margin-top: 20rpx; padding: 0 30rpx; } .category-title { font-size: 30rpx; font-weight: bold; color: #333; padding: 30rpx 0 20rpx; display: block; border-bottom: 1rpx solid #f0f0f0; } .menu-item { display: flex; padding: 24rpx 0; border-bottom: 1rpx solid #f5f5f5; } .item-img { width: 160rpx; height: 160rpx; border-radius: 12rpx; margin-right: 20rpx; } .item-info { flex: 1; display: flex; flex-direction: column; justify-content: space-between; } .item-name { font-size: 28rpx; font-weight: bold; color: #333; } .item-desc { font-size: 24rpx; color: #999; margin-top: 8rpx; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .item-bottom { display: flex; justify-content: space-between; align-items: center; margin-top: 16rpx; } .item-price { font-size: 32rpx; color: #ff4d4f; font-weight: bold; } .cart-control { display: flex; align-items: center; gap: 16rpx; } .count { font-size: 28rpx; color: #07c160; font-weight: bold; min-width: 40rpx; text-align: center; } .cart-bar { position: fixed; bottom: 0; left: 0; right: 0; height: 120rpx; background: #2d2d2d; display: flex; align-items: center; justify-content: space-between; padding: 0 30rpx; box-shadow: 0 -4rpx 20rpx rgba(0,0,0,0.1); } .cart-info { display: flex; align-items: center; gap: 20rpx; } .cart-icon-wrap { position: relative; width: 80rpx; height: 80rpx; background: #07c160; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin-top: -40rpx; } .cart-badge { position: absolute; top: -10rpx; right: -10rpx; background: #ff4d4f; color: #fff; font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 20rpx; } .total-label { font-size: 28rpx; color: #999; } .total-amount { font-size: 36rpx; color: #ff4d4f; font-weight: bold; margin-left: 10rpx; } .btn-submit { background: linear-gradient(135deg, #07c160, #06ad56); color: #fff; border: none; border-radius: 50rpx; padding: 24rpx 60rpx; font-size: 32rpx; font-weight: bold; } .cart-popup { background: #fff; border-radius: 30rpx 30rpx 0 0; padding: 30rpx; max-height: 60vh; } .popup-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20rpx; } .popup-title { font-size: 34rpx; font-weight: bold; color: #333; } .clear-btn { font-size: 28rpx; color: #999; } .cart-item { display: flex; align-items: center; padding: 20rpx 0; border-bottom: 1rpx solid #f0f0f0; } .cart-item-name { flex: 1; font-size: 28rpx; color: #333; } .cart-item-control { display: flex; align-items: center; gap: 24rpx; margin: 0 20rpx; } .minus-btn, .plus-btn { width: 48rpx; height: 48rpx; border-radius: 50%; background: #f5f5f5; display: flex; align-items: center; justify-content: center; font-size: 32rpx; color: #333; } .cart-item-count { font-size: 28rpx; font-weight: bold; min-width: 40rpx; text-align: center; } .cart-item-price { font-size: 28rpx; color: #ff4d4f; font-weight: bold; min-width: 120rpx; text-align: right; } </style>🗄️ 三、数据库表设计
sql
-- 店铺表 CREATE TABLE shop ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '店铺名', cover_img VARCHAR(500) COMMENT '封面图', description TEXT COMMENT '描述', address VARCHAR(200) COMMENT '地址', phone VARCHAR(20) COMMENT '电话', business_hours VARCHAR(50) COMMENT '营业时间', status TINYINT DEFAULT 1 COMMENT '1-营业 0-休业', qrcode_content TEXT COMMENT '二维码内容(JSON)', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) COMMENT '店铺表'; -- 桌台表 CREATE TABLE table_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shop_id BIGINT NOT NULL COMMENT '店铺ID', table_no VARCHAR(20) NOT NULL COMMENT '桌号 A01', qrcode_content VARCHAR(500) COMMENT '桌台二维码内容', status TINYINT DEFAULT 1 COMMENT '1-空闲 0-占用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_shop_table (shop_id, table_no) ) COMMENT '桌台表'; -- 扫码记录表(用于数据分析) CREATE TABLE scan_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, shop_id BIGINT NOT NULL, table_no VARCHAR(20) COMMENT '桌号', scan_type VARCHAR(20) COMMENT 'SHOP/DELIVERY/COUPON', user_id BIGINT COMMENT '用户ID(可选)', openid VARCHAR(100) COMMENT '微信openid', scan_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_shop_time (shop_id, scan_time) ) COMMENT '扫码记录表';📊 四、扫码流程总结
用户扫码 ↓ uni.scanCode() 获取二维码内容 ↓ 请求后端 /api/scan/parse?code=xxx ↓ 后端解析协议头 CAMPUS:// ↓ 根据类型(SHOP/DELIVERY/COUPON/PICKUP)分发 ↓ 返回对应数据(店铺+菜单/配送费/优惠券/取餐码) ↓ 前端跳转对应页面💡 关键技术点
| 技术 | 用途 |
|---|---|
✅uni.scanCode() | 微信扫码API |
✅ 协议头识别CAMPUS:// | 区分业务类型 |
| ✅ URL参数解析 | 提取店铺ID、桌号等 |
| ✅ Redis记录扫码日志 | 数据统计分析 |
| ✅ 分布式锁 | 防止重复接单 |
| ✅ JWT认证 | 用户身份验证 |
🚀需要完整的连锁店多店铺管理、奶茶点餐特殊逻辑(规格选择)、同城外卖配送系统代码吗?请告诉我!🔥
