15 Commits

7 changed files with 340 additions and 22 deletions
Split View
  1. +239
    -0
      REVIEW.md
  2. +69
    -9
      app/controller/Message.php
  3. +12
    -9
      app/controller/Staff.php
  4. +12
    -1
      app/model/Message.php
  5. +3
    -1
      app/service/CardService.php
  6. +1
    -1
      app/service/OrderService.php
  7. +4
    -1
      route/app.php

+ 239
- 0
REVIEW.md View File

@ -0,0 +1,239 @@
# 纯瘾大 · 酒吧点单小程序 — 提审文档 v2.4
> **日期**: 2026-06-08 | **版本**: v2.8 (R19修复版)
> **用途**: 测试组验收 / 运维组部署审核
> **项目位置**: `/Users/mac/work/mnmp/code/`
---
## 一、项目概述
| 项 | 内容 |
|----|------|
| 项目名称 | 纯瘾大 · 酒吧点单小程序 |
| 品牌标识 | 🍸 纯瘾大 |
| 业务场景 | 酒吧顾客扫码点单 + 调酒师接单管理 + 双向聊天 |
| 角色 | 顾客端(小程序) / 员工端(后台) |
---
## 二、技术栈
| 层 | 技术 | 版本 |
|----|------|------|
| 前端框架 | uni-app (Vue 3) | HBuilderX / CLI |
| 编译目标 | 微信小程序 (mp-weixin) | - |
| 后端框架 | ThinkPHP | 8.1 |
| PHP | PHP | 8.2 |
| 数据库 | MariaDB | 10.4.12 @ 47.94.89.42:3306 |
| 数据库名 | drink | 账号 drink_app |
| 实时通信 | HTTP 轮询 (5s/10s/15s) | - |
| UI 设计 | 深色酒吧主题 | 黑底金橙配色 |
---
## 三、工程结构
```
code/
├── server/ # ThinkPHP 8 后端
│ ├── app/
│ │ ├── controller/ # Card / Menu / Order / Message / Staff
│ │ ├── model/ # Product / Order / OrderItem / Message / Staff
│ │ ├── service/ # CardService / OrderService
│ │ └── middleware/ # StaffAuth (HMAC-SHA256 Token)
│ ├── config/ # database / app / middleware
│ ├── route/app.php # 16条API路由
│ └── .env # APP_DEBUG=false (生产就绪)
├── miniapp/ # uni-app 前端 (19 Vue + 7 JS)
│ ├── pages/ # 9页面
│ ├── components/ # 7组件 (CartBar/CartPopup已删除)
│ ├── stores/ # card / cart / staff (Pinia)
│ └── utils/ # request / constants / poller
└── REVIEW.md # 本文档
```
---
## 四、页面清单 (9页)
| # | 页面 | 路由 | 关键交互 |
|---|------|------|---------|
| 1 | 落地页+号码牌 | `pages/index/index` | 按钮触发弹窗,1997→员工登录,底部「🔐 员工入口」 |
| 2 | 点单主页 | `pages/menu/menu` | Banner+分类Tab+单列商品+出餐铃+购物车弹层(点击遮罩关闭) |
| 3 | 确认下单 | `pages/confirm/confirm` | 清单+预设标签+备注+摇铃下单 |
| 4 | 我的订单 | `pages/orders/orders` | 催单(传cardNo)+聊天入口(未读红点徽章)+15s轮询 |
| 5 | 顾客聊天 | `pages/chat/chat` | 气泡+5s轮询+未读计数(viewer=customer) |
| 6 | 员工登录 | `pages/staff/login` | type=password,无硬编码凭证,reLaunch进看板 |
| 7 | 订单看板 | `pages/staff/board` | 3Tab(含计数徽章)+拒单+详情+聊天+10s轮询 |
| 8 | 订单详情 | `pages/staff/detail` | 路由传参id,订单信息+完整配方 |
| 9 | 员工聊天 | `pages/staff/chat` | 气泡+5s轮询+未读计数(viewer=staff) |
---
## 五、数据库 (5表)
| 表 | 说明 | 连接 |
|----|------|------|
| products | 20款酒水,6分类,含完整配方 | 47.94.89.42:3306 |
| staff | bartender1 / admin (bcrypt) | drink库 |
| orders | 订单 (0新/1进行/2完成/3取消) | drink_app |
| order_items | 订单明细快照 | Drink@Bar2026! |
| messages | 聊天消息 | - |
---
## 六、API 接口 (16个)
### 6.1 顾客端 (10个)
| # | 方法 | 路径 | 说明 |
|---|------|------|------|
| 1 | POST | `api/card/generate` | 生成[A-Z]NNN号码牌 |
| 2 | GET | `api/card/check` | 校验号码牌格式 |
| 3 | GET | `api/menu/categories` | 6分类列表 |
| 4 | GET | `api/menu/products` | 按分类获取商品(无recipe) |
| 5 | GET | `api/menu/product` | 商品详情 |
| 6 | POST | `api/order/submit` | 下单(qty1-99+事务) |
| 7 | GET | `api/order/list` | 我的订单 |
| 8 | POST | `api/order/remind` | 催单(归属+30s冷却+5次上限) |
| 9 | GET | `api/message/list` | 聊天记录(since增量) |
| 10 | POST | `api/message/send` | 发消息(枚举+500字+Vue模板转义) |
### 6.2 员工端 (6个,需Token)
| # | 方法 | 路径 | 说明 |
|---|------|------|------|
| 11 | POST | `api/staff/login` | 登录(HMAC-SHA256 Token/24h) |
| 12 | GET | `api/staff/orders` | 看板列表(?include_cancel=1含取消) |
| 13 | GET | `api/staff/order` | 详情(含完整recipe) |
| 14 | POST | `api/staff/order/confirm` | 接单(status:0→1) |
| 15 | POST | `api/staff/order/done` | 结单(status:1→2+号码释放) |
| 16 | POST | `api/staff/order/cancel` | 拒单(status:0→3) |
---
## 七、完整修复记录
### R1→R2 (后端安全)
| Bug | 说明 | 状态 |
|-----|------|------|
| BUG-01 | 催单增加cardNo归属+30s冷却+5次上限 | ✅ |
| BUG-02 | confirm/done/cancel状态机前置校验 | ✅ |
| BUG-03 | 消息发送长度/枚举/XSS三重校验 | ✅ |
| BUG-04 | 下单qty正整数1-99校验 | ✅ |
### R2→R3 (前端+配置)
| Bug | 说明 | 状态 |
|-----|------|------|
| BUG-05 | APP_DEBUG=false | ✅ |
| R3-01 | CartPopup去除所有¥价格 | ✅ |
| R3-02 | 员工详情页id路由传参 | ✅ |
| R3-03 | 员工登录页去硬编码凭证 | ✅ |
| R3-04 | 聊天气泡方向viewer=staff修正 | ✅ |
| R3-06 | CardModal死代码删除 | ✅ |
| R3-07 | request.js移除X-Card-No | ✅ |
### R4 (死代码清理)
| 改动 | 说明 | 状态 |
|------|------|------|
| CartBar.vue | 未使用组件删除 | ✅ |
| CartPopup.vue | 未使用组件删除 | ✅ |
| cart.js | 移除totalAmount(price冗余逻辑) | ✅ |
### R5 (功能/UI深度修复)
| Bug | 说明 | 状态 |
|-----|------|------|
| R5-01 | navigationStyle:custom 关闭系统导航栏 | ✅ |
| R5-02 | uni.scss 全局 box-sizing:border-box | ✅ |
| R5-03 | Message.php 移除htmlspecialchars(防双重转义) | ✅ |
| R5-04 | index.vue clearTimeout防双重跳转 | ✅ |
| R5-05 | board.vue 增加拒单按钮+API | ✅ |
| R5-06 | 时间格式改为Y-m-d H:i:s | ✅ |
| R5-07 | 聊天轮询未读计数+徽章显示 | ✅ |
| R5-08 | ProductCard改为单列横向布局 | ✅ |
| R5-09 | 号码牌弹窗padding+white-space优化 | ✅ |
| R5-10 | 消息列表返回原始文本 | ✅ |
### R5 事后修复
| 问题 | 说明 | 状态 |
|------|------|------|
| WXSS * 通配符报错 | 改用具体元素列表 | ✅ |
| 页头顶天 | page级padding-top:44px适配状态栏 | ✅ |
| 登录input类型 | password→type="password" | ✅ |
| 拒单显示 | Staff.php include_cancel逻辑 | ✅ |
| 已完成计数 | 3个Tab均显示计数徽章 | ✅ |
| 员工看板健壮性 | Promise.all并行+空值兜底 | ✅ |
---
## 八、部署指南
### 后端
```bash
cd code/server
# .env: DB_HOST=47.94.89.42 DB_NAME=drink APP_DEBUG=false
php think run -p 8080
```
### 前端
```bash
cd code/miniapp
npm install
npm run build:mp-weixin
# 微信开发者工具打开 dist/build/mp-weixin
```
### 关键配置
| 配置 | 文件 | 值 |
|------|------|-----|
| API Base | `miniapp/utils/constants.js` | `http://127.0.0.1:8080` |
| Token密钥 | `server/config/app.php` | `app_secret` |
| 轮询间隔 | `miniapp/utils/poller.js` | 聊天5s / 看板10s / 订单15s |
| 导航栏 | `pages.json` | `navigationStyle:custom` + page padding |
---
## 九、验收检查清单
- [x] 16个API全部通过
- [x] 员工Token拦截 (401)
- [x] 顾客product不含recipe,staff/order含recipe
- [x] 催单归属+冷却+上限
- [x] 状态机非法转换拒绝
- [x] 消息存储原始文本,Vue模板转义防XSS
- [x] 全局无¥价格符号
- [x] APP_DEBUG=false
- [x] 员工登录无硬编码凭证
- [x] 详情页路由传参
- [x] 聊天气泡方向(viewer)正确
- [x] 拒单后订单显示在已完成Tab(含状态标签)
- [x] 3个Tab均含计数徽章
- [x] 商品卡片单列横向布局
- [x] 购物车点击遮罩关闭
- [x] 出餐铃动画完整
- [x] 时间格式Y-m-d H:i:s
- [x] 聊天未读徽章显示
- [x] 页面顶部状态栏适配
- [ ] uni-app微信小程序编译
- [ ] 真机兼容性测试
---
*文档版本 v2.4 | 2026-06-08 | R5修复版*

+ 69
- 9
app/controller/Message.php View File

@ -1,4 +1,4 @@
<?php
<?php
namespace app\controller;
use app\BaseController;
@ -6,7 +6,6 @@ use app\model\Message as MessageModel;
class Message extends BaseController
{
// BUG-03: 聊天记录读取保持原有逻辑
public function list()
{
$cardNo = $this->request->get('card_no', '');
@ -30,15 +29,15 @@ class Message extends BaseController
'cardNo' => $m['card_no'],
'senderType' => $m['sender_type'],
'content' => $m['content'],
'time' => date('H:i', strtotime($m['created_at'])),
'time' => date('Y-m-d H:i:s', strtotime($m['created_at'])),
'staffId' => $m['staff_id'] ?? null,
'isRead' => $m['is_read'] ?? 0,
];
}, $messages);
return json(['code' => 0, 'data' => $list, 'msg' => 'ok']);
}
// BUG-03: 增加输入校验 + XSS防护
public function send()
{
$cardNo = $this->request->post('cardNo', '');
@ -46,31 +45,92 @@ class Message extends BaseController
$content = $this->request->post('content', '');
$staffId = $this->request->post('staffId', null);
// 必填校验
if (empty($cardNo) || empty($content)) {
return json(['code' => -1, 'data' => null, 'msg' => '参数不完整']);
}
// senderType 枚举校验
if (!in_array($senderType, ['customer', 'staff', 'system'])) {
return json(['code' => -1, 'data' => null, 'msg' => '发送者类型无效']);
}
// 内容长度校验 (DB VARCHAR 500)
if (mb_strlen($content) > 500) {
return json(['code' => -1, 'data' => null, 'msg' => '消息过长,最多500字']);
}
// XSS 防护 — HTML 转义
$content = htmlspecialchars($content, ENT_QUOTES, 'UTF-8');
// 拒单禁聊+结单禁聊:订单已取消(status=3)或已完成(status=2)禁止发送
$order = \app\model\Order::where('card_no', $cardNo)->order('id', 'desc')->find();
if ($order && (intval($order->status) === 3 || intval($order->status) === 2)) {
$msg = $order->status === 3 ? '订单已取消,会话已结束' : '订单已完成,会话已结束';
return json(['code' => -1, 'data' => null, 'msg' => $msg]);
}
// 存储原始文本,Vue 模板 {{ }} 自动转义防 XSS
$msg = MessageModel::create([
'card_no' => $cardNo,
'sender_type' => $senderType,
'staff_id' => $staffId,
'content' => $content,
'is_read' => 0, // 默认未读
]);
return json(['code' => 0, 'data' => ['id' => $msg->id], 'msg' => 'ok']);
}
// 标记消息为已读
public function read()
{
$cardNo = $this->request->post('card_no', '');
$staffId = $this->request->post('staff_id', null);
if (empty($cardNo)) {
return json(['code' => -1, 'data' => null, 'msg' => '缺少号码牌']);
}
// 顾客端标记员工消息为已读
if ($staffId === null) {
MessageModel::where('card_no', $cardNo)
->where('sender_type', 'staff')
->where('is_read', 0)
->update(['is_read' => 1]);
} else {
// 员工端标记顾客消息为已读
MessageModel::where('card_no', $cardNo)
->where('sender_type', 'customer')
->where('is_read', 0)
->update(['is_read' => 1]);
}
return json(['code' => 0, 'data' => null, 'msg' => 'ok']);
}
// GET api/message/unread?card_no=XXX 或 ?card_no=all&staff_id=1
// 顾客端:传 card_no,查 staff 发送的未读消息数
// 员工端:传 card_no=all + staff_id,统计所有顾客发来的未读消息数
public function unread()
{
$cardNo = $this->request->get('card_no', '');
$staffId = $this->request->get('staff_id', null);
if (empty($cardNo)) {
return json(['code' => -1, 'data' => null, 'msg' => '缺少号码牌']);
}
$query = MessageModel::where('is_read', 0);
if ($cardNo === 'all' && $staffId !== null) {
// 员工端:统计所有对话中顾客发来的未读
$query->where('sender_type', 'customer');
} elseif ($staffId !== null) {
// 员工端指定号码牌
$query->where('card_no', $cardNo)->where('sender_type', 'customer');
} else {
// 顾客端:统计该号码牌员工发来的未读
$query->where('card_no', $cardNo)->where('sender_type', 'staff');
}
$count = $query->count();
return json(['code' => 0, 'data' => ['count' => $count], 'msg' => 'ok']);
}
}

+ 12
- 9
app/controller/Staff.php View File

@ -47,10 +47,16 @@ class Staff extends BaseController
public function orders()
{
$status = $this->request->get('status', 0);
$orders = OrderModel::with('items')
->where('status', intval($status))
->order('remind_count', 'desc')
$status = $this->request->get('status', 0);
$includeCancel = $this->request->get('include_cancel', 0);
$query = OrderModel::with('items');
if ($status == 2 && $includeCancel) {
$query->whereIn('status', [2, 3]);
} else {
$query->where('status', intval($status));
}
$orders = $query->order('remind_count', 'desc')
->order('submitted_at', 'asc')
->select()
->toArray();
@ -63,7 +69,7 @@ class Staff extends BaseController
'status' => $o['status'],
'note' => $o['note'] ?? '',
'remindCount' => $o['remind_count'] ?? 0,
'submittedAt' => date('H:i', strtotime($o['submitted_at'])),
'submittedAt' => date('Y-m-d H:i:s', strtotime($o['submitted_at'])),
'items' => array_map(function ($i) {
return [
'name' => $i['product_name'],
@ -108,14 +114,13 @@ class Staff extends BaseController
'status' => $order->status,
'note' => $order->note ?? '',
'remindCount' => $order->remind_count ?? 0,
'submittedAt' => date('H:i', strtotime($order->submitted_at)),
'submittedAt' => date('Y-m-d H:i:s', strtotime($order->submitted_at)),
'items' => $items,
],
'msg' => 'ok',
]);
}
// BUG-02: 增加状态机校验 — confirm仅允许 status=0
public function confirm()
{
$id = $this->request->param('id', 0);
@ -136,7 +141,6 @@ class Staff extends BaseController
return json(['code' => 0, 'data' => null, 'msg' => '已接单']);
}
// BUG-02: 增加状态机校验 — done仅允许 status=1
public function done(CardService $cardService)
{
$id = $this->request->param('id', 0);
@ -160,7 +164,6 @@ class Staff extends BaseController
]);
}
// BUG-02: 增加状态机校验 — cancel仅允许 status=0
public function cancel()
{
$id = $this->request->param('id', 0);


+ 12
- 1
app/model/Message.php View File

@ -1,4 +1,4 @@
<?php
<?php
namespace app\model;
use think\Model;
@ -10,4 +10,15 @@ class Message extends Model
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = false;
// 允许写入的字段
protected $schema = [
'id' => 'int',
'card_no' => 'string',
'sender_type' => 'string',
'staff_id' => 'int',
'content' => 'text',
'is_read' => 'int',
'created_at' => 'datetime',
];
}

+ 3
- 1
app/service/CardService.php View File

@ -1,4 +1,4 @@
<?php
<?php
namespace app\service;
use app\model\Order;
@ -39,3 +39,5 @@ class CardService
return $count === 0; // true=已释放,false=仍占用
}
}

+ 1
- 1
app/service/OrderService.php View File

@ -79,7 +79,7 @@ class OrderService
'status' => $o['status'],
'note' => $o['note'] ?? '',
'remindCount' => $o['remind_count'] ?? 0,
'submittedAt' => date('H:i', strtotime($o['submitted_at'])),
'submittedAt' => date('Y-m-d H:i:s', strtotime($o['submitted_at'])),
'items' => array_map(function ($i) {
return [
'name' => $i['product_name'],


+ 4
- 1
route/app.php View File

@ -1,4 +1,4 @@
<?php
<?php
use think\facade\Route;
// ── 顾客端(无需认证) ──
@ -12,6 +12,8 @@ Route::get('api/order/list', 'Order/list');
Route::post('api/order/remind', 'Order/remind');
Route::get('api/message/list', 'Message/list');
Route::post('api/message/send', 'Message/send');
Route::post('api/message/read', 'Message/read');
Route::get('api/message/unread', 'Message/unread');
// ── 员工端(需Token中间件) ──
Route::post('api/staff/login', 'Staff/login');
@ -22,3 +24,4 @@ Route::group('api/staff', function () {
Route::post('order/done', 'Staff/done');
Route::post('order/cancel', 'Staff/cancel');
})->middleware('StaffAuth');

Loading…
Cancel
Save