在大型PHP项目开发中,业务逻辑的高度耦合往往是系统维护的最大痛点。ThinkPHP 8作为目前PHP生态中最活跃的框架之一,其全新升级的事件系统与消息队列组件为开发者提供了一套优雅的解耦方案。本文将带你从零搭建一个基于事件驱动的订单处理系统,完整覆盖自定义事件、多监听器注册、队列异步分发以及生产环境调优的全链路实践。
一、为什么需要在ThinkPHP项目中引入事件驱动架构
传统的订单处理流程通常是”一条龙”式的同步调用:接收请求→校验库存→创建订单→扣减库存→发送短信→记录日志→返回响应。这种模式存在三个明显缺陷:
- 响应延迟高:所有步骤串行执行,用户需要等待所有非核心操作(如发送通知)完成后才能得到反馈。
- 代码耦合严重:订单模块需要显式调用短信服务、日志服务、统计服务等,任何一个子服务变更都会影响主流程。
- 故障传播风险:短信网关超时可能导致整个下单请求失败,核心业务被非核心操作拖垮。
事件驱动架构的核心思想是:主流程只负责完成核心操作并发布事件,其他非核心逻辑通过监听器响应事件来触发。配合消息队列后,监听器还可以被推送到后台异步执行,从而实现真正的业务解耦与性能优化。
ThinkPHP 8对事件系统的设计进行了重构,支持监听器级别的队列配置,开发者无需额外编写任务类即可将事件响应异步化,这在整个PHP框架生态中都是一个相当亮眼的设计。
二、环境准备与项目初始化
在开始之前,请确保你的开发环境满足以下条件:
- PHP 8.0 及以上版本(ThinkPHP 8 的最低要求)
- Composer 2.x 已安装
- Redis 服务已启动(用于队列驱动,推荐 Redis 6.0+)
- MySQL 5.7 或 8.0(用于业务数据持久化)
使用 Composer 创建 ThinkPHP 8 项目:
composer create-project topthink/think tp8-event-demo
cd tp8-event-demo
项目创建完成后,需要安装队列扩展包。ThinkPHP 8 的队列组件需要单独引入:
composer require topthink/think-queue
接着配置队列驱动。打开项目根目录下的 .env 文件(如不存在则从 .example.env 复制),设置队列相关配置:
# 队列驱动类型:sync(同步) / redis / database
QUEUE_CONNECTION=redis
# Redis连接配置
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_SELECT=0
如果选择 database 驱动,还需要执行以下命令创建队列表:
php think queue:table
php think migrate:run
对于生产环境,推荐使用 Redis 驱动,其性能远优于数据库驱动,且支持延迟任务和优先级队列。
三、ThinkPHP 8事件系统核心机制速览
在正式编码之前,有必要理清 ThinkPHP 8 事件系统的几个关键概念:
- 事件类(Event):封装事件数据的载体,通常继承自基础事件类,放置在
app/event目录下。 - 监听器(Listener):响应事件的处理器,放置在
app/listener目录下。一个事件可以绑定多个监听器。 - 事件调度器(Dispatcher):负责管理事件与监听器的映射关系,并在事件触发时按序调用对应的监听器。
- 事件订阅者(Subscriber):将多个相关的事件监听逻辑集中在一个类中管理,适合复杂的业务场景。
ThinkPHP 8 的事件配置集中在 app/event.php 文件中(该文件默认不存在,需要手动创建)。框架启动时会自动加载该配置并注册所有事件映射。此外,监听器可以通过实现 shouldQueue 接口来声明自己需要异步执行,框架会自动将其推入队列。
下面这张关系图清晰地展示了事件从触发到被消费的完整链路(文字描述):
业务代码触发事件 → Event::trigger() → 调度器查找监听器映射
↓
监听器A(同步)→ 立即执行
监听器B(异步)→ 推入Redis队列 → 队列Worker消费
监听器C(同步)→ 立即执行
这种设计使得开发者可以灵活地决定每个监听器的执行策略——核心的、必须同步完成的操作立即执行;耗时的、允许延迟的操作走队列异步处理。
四、实战案例:电商订单处理系统的完整搭建
我们以电商中最典型的”用户下单”场景为例,设计如下业务流程:
- 用户提交订单,系统校验数据并创建订单记录(核心操作,同步完成)。
- 订单创建成功后,扣减对应商品的库存(核心操作,同步完成)。
- 向用户发送下单成功的短信通知(非核心操作,异步完成)。
- 将订单操作记录写入审计日志(非核心操作,异步完成)。
- 更新用户的消费统计信息(非核心操作,异步完成)。
4.1 创建订单事件类
在 app/event 目录下创建 OrderCreated.php(如果目录不存在则先创建):
<?php
declare(strict_types=1);
namespace appevent;
use thinkEvent;
/**
* 订单创建成功事件
* 携带完整的订单数据,供各监听器使用
*/
class OrderCreated extends Event
{
/**
* 订单数据
* @var array
*/
public array $orderData;
/**
* 构造函数
* @param array $orderData 包含订单号、用户ID、商品信息、金额等
*/
public function __construct(array $orderData)
{
$this->orderData = $orderData;
}
/**
* 获取订单号(便捷方法)
*/
public function getOrderNo(): string
{
return $this->orderData['order_no'] ?? '';
}
/**
* 获取用户ID(便捷方法)
*/
public function getUserId(): int
{
return (int) ($this->orderData['user_id'] ?? 0);
}
/**
* 获取订单金额(便捷方法)
*/
public function getAmount(): float
{
return (float) ($this->orderData['amount'] ?? 0.00);
}
}
这里让事件类继承自 thinkEvent 并非强制要求,继承基类可以获得一些便利方法,你也可以完全自定义一个普通类作为事件载体。关键是事件类要清晰地封装业务数据,方便监听器消费。
4.2 编写监听器——同步扣减库存
库存扣减是核心操作,必须在订单创建的同时完成,因此设计为同步监听器。在 app/listener 目录下创建 DeductStockListener.php:
<?php
declare(strict_types=1);
namespace applistener;
use appeventOrderCreated;
use thinkfacadeDb;
use thinkfacadeLog;
/**
* 库存扣减监听器
* 同步执行,确保订单创建后库存立即更新
*/
class DeductStockListener
{
/**
* 处理事件
* @param OrderCreated $event
* @return void
* @throws thinkException
*/
public function handle(OrderCreated $event): void
{
$orderData = $event->orderData;
$orderNo = $event->getOrderNo();
Log::info("开始扣减库存,订单号:{$orderNo}");
// 开启数据库事务,保证库存扣减的原子性
Db::transaction(function () use ($orderData, $orderNo) {
foreach ($orderData['items'] as $item) {
$productId = $item['product_id'];
$quantity = $item['quantity'];
// 使用行锁防止超卖
$affected = Db::table('products')
->where('id', $productId)
->where('stock', '>=', $quantity)
->decr('stock', $quantity);
if ($affected === 0) {
Log::error("库存扣减失败,订单号:{$orderNo},商品ID:{$productId}");
throw new thinkException("商品 {$productId} 库存不足");
}
Log::info("商品 {$productId} 扣减库存 {$quantity} 件成功");
}
});
Log::info("库存扣减完成,订单号:{$orderNo}");
}
}
这里使用了数据库事务和行级锁(通过 where('stock', '>=', $quantity) 配合 decr 实现乐观锁效果),确保高并发场景下不会出现超卖问题。事务中任何一个商品库存不足都会整体回滚。
4.3 编写异步监听器——短信通知
短信通知属于非核心操作,耗时长且依赖外部服务,适合走队列异步处理。在 app/listener 目录下创建 SendSmsListener.php:
<?php
declare(strict_types=1);
namespace applistener;
use appeventOrderCreated;
use thinkfacadeLog;
use thinkqueueShouldQueue;
/**
* 短信通知监听器
* 实现 ShouldQueue 接口,框架自动将其推入队列异步执行
*/
class SendSmsListener implements ShouldQueue
{
/**
* 队列名称(可选,不指定则使用默认队列)
* @var string
*/
public string $queue = 'order_notify';
/**
* 延迟执行秒数(可选,例如等待30秒后发送)
* @var int
*/
public int $delay = 30;
/**
* 失败重试次数
* @var int
*/
public int $tries = 3;
/**
* 处理事件
* @param OrderCreated $event
* @return void
*/
public function handle(OrderCreated $event): void
{
$userId = $event->getUserId();
$orderNo = $event->getOrderNo();
$amount = $event->getAmount();
Log::info("队列任务开始:发送下单短信,订单号:{$orderNo}");
// 模拟从数据库获取用户手机号
$user = thinkfacadeDb::table('users')
->where('id', $userId)
->field('phone, nickname')
->find();
if (empty($user) || empty($user['phone'])) {
Log::warning("用户手机号为空,跳过短信发送,用户ID:{$userId}");
return;
}
// 模拟调用第三方短信网关API
$smsResult = $this->sendSmsViaGateway(
$user['phone'],
"尊敬的{$user['nickname']},您的订单{$orderNo}已确认,金额¥{$amount},我们将尽快为您发货。"
);
if ($smsResult) {
Log::info("短信发送成功,订单号:{$orderNo},手机号:{$user['phone']}");
} else {
Log::error("短信发送失败,订单号:{$orderNo},手机号:{$user['phone']}");
// 抛出异常会触发队列重试机制
throw new RuntimeException("短信网关返回失败");
}
}
/**
* 模拟短信网关调用
* 实际项目中替换为具体的短信服务商SDK
*/
private function sendSmsViaGateway(string $phone, string $content): bool
{
// 模拟网络延迟
usleep(500000); // 500毫秒
// 模拟95%的成功率
return (mt_rand(1, 100) <= 95);
}
}
关键在于 implements ShouldQueue 这个接口。ThinkPHP 8 会在触发事件时自动检测监听器是否实现了该接口,如果实现了,则不会立即执行 handle 方法,而是将监听器序列化后推入 Redis 队列,由独立的队列 Worker 进程消费执行。
属性 $queue 指定了队列名称,便于对不同业务的消息进行分类管理;$delay 设置延迟30秒执行,适用于需要短暂等待数据落盘后再发送通知的场景;$tries 定义失败重试次数。
4.4 编写异步监听器——操作日志与用户统计
在 app/listener 目录下创建 RecordOrderLogListener.php:
<?php
declare(strict_types=1);
namespace applistener;
use appeventOrderCreated;
use thinkfacadeDb;
use thinkfacadeLog;
use thinkqueueShouldQueue;
/**
* 订单操作日志记录监听器
* 异步写入审计日志表
*/
class RecordOrderLogListener implements ShouldQueue
{
public string $queue = 'order_log';
public int $tries = 2;
public function handle(OrderCreated $event): void
{
$orderNo = $event->getOrderNo();
$userId = $event->getUserId();
$logData = [
'order_no' => $orderNo,
'user_id' => $userId,
'action' => 'order_created',
'detail' => json_encode($event->orderData, JSON_UNESCAPED_UNICODE),
'ip_address' => request()->ip() ?? '127.0.0.1',
'created_at' => date('Y-m-d H:i:s'),
];
Db::table('order_operation_logs')->insert($logData);
Log::info("订单操作日志已记录,订单号:{$orderNo}");
}
}
再创建 UpdateUserStatListener.php,用于异步更新用户消费统计:
<?php
declare(strict_types=1);
namespace applistener;
use appeventOrderCreated;
use thinkfacadeDb;
use thinkfacadeLog;
use thinkqueueShouldQueue;
/**
* 用户消费统计更新监听器
* 异步更新用户累计消费金额和订单数
*/
class UpdateUserStatListener implements ShouldQueue
{
public string $queue = 'user_stat';
public int $tries = 3;
public function handle(OrderCreated $event): void
{
$userId = $event->getUserId();
$amount = $event->getAmount();
// 使用原子操作更新统计字段
Db::table('user_statistics')
->where('user_id', $userId)
->inc('total_orders', 1)
->inc('total_amount', $amount)
->update();
Log::info("用户统计已更新,用户ID:{$userId},本次消费:{$amount}");
}
}
这里的 update() 方法配合 inc() 使用的是数据库原子操作,即使在队列并发消费的场景下也能保证数据准确性,无需额外加锁。
4.5 注册事件与监听器的映射关系
在 app 目录下创建 event.php 配置文件:
<?php
// app/event.php
return [
// 绑定事件与监听器
'bind' => [
// 订单创建事件
appeventOrderCreated::class => [
applistenerDeductStockListener::class, // 同步:扣减库存
applistenerSendSmsListener::class, // 异步:短信通知
applistenerRecordOrderLogListener::class, // 异步:操作日志
applistenerUpdateUserStatListener::class, // 异步:用户统计
],
],
// 事件订阅者(可选,用于更复杂的场景)
'subscribe' => [
// appsubscribeOrderSubscriber::class,
],
];
监听器数组中的顺序决定了同步监听器的执行顺序。对于实现了 ShouldQueue 的异步监听器,它们在被推入队列时保持配置顺序,但实际消费顺序取决于队列 Worker 的调度策略。
如果你更习惯使用服务提供者来注册事件,也可以在 appAppServiceProvider 的 boot 方法中动态绑定,但对于大多数场景,使用 event.php 配置文件是最清晰的方式。
4.6 编写订单业务逻辑与事件触发
在 app/controller 目录下创建 OrderController.php,模拟完整的下单流程:
<?php
declare(strict_types=1);
namespace appcontroller;
use appeventOrderCreated;
use thinkfacadeDb;
use thinkfacadeEvent;
use thinkfacadeLog;
use thinkfacadeValidate;
use thinkResponse;
class OrderController
{
/**
* 创建订单接口
* POST /api/order/create
*
* 请求参数示例:
* {
* "user_id": 1001,
* "items": [
* {"product_id": 10, "quantity": 2},
* {"product_id": 15, "quantity": 1}
* ],
* "address_id": 5,
* "remark": "请尽快发货"
* }
*/
public function create(): Response
{
// 1. 参数校验
$data = request()->post();
$validate = Validate::rule([
'user_id' => 'require|integer|gt:0',
'items' => 'require|array',
'address_id' => 'require|integer|gt:0',
])->message([
'user_id.require' => '用户ID不能为空',
'items.require' => '商品列表不能为空',
'address_id.require' => '收货地址不能为空',
]);
if (!$validate->check($data)) {
return json([
'code' => 422,
'msg' => $validate->getError(),
]);
}
// 2. 计算订单金额(简化示例,实际需查询商品价格)
$totalAmount = 0.00;
$validItems = [];
foreach ($data['items'] as $item) {
$product = Db::table('products')
->where('id', $item['product_id'])
->field('id, price, name')
->find();
if (!$product) {
return json([
'code' => 404,
'msg' => "商品 {$item['product_id']} 不存在",
]);
}
$validItems[] = [
'product_id' => $product['id'],
'product_name' => $product['name'],
'price' => $product['price'],
'quantity' => $item['quantity'],
'subtotal' => $product['price'] * $item['quantity'],
];
$totalAmount += $product['price'] * $item['quantity'];
}
// 3. 生成订单号并写入数据库
$orderNo = $this->generateOrderNo();
Db::startTrans();
try {
$orderId = Db::table('orders')->insertGetId([
'order_no' => $orderNo,
'user_id' => $data['user_id'],
'address_id' => $data['address_id'],
'total_amount'=> $totalAmount,
'status' => 0, // 0-待处理
'remark' => $data['remark'] ?? '',
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
if (!$orderId) {
throw new thinkException('订单创建失败');
}
// 写入订单商品明细
foreach ($validItems as &$vi) {
$vi['order_id'] = $orderId;
}
Db::table('order_items')->insertAll($validItems);
Db::commit();
Log::info("订单创建成功,订单号:{$orderNo},金额:{$totalAmount}");
} catch (Throwable $e) {
Db::rollback();
Log::error("订单创建失败:{$e->getMessage()}");
return json([
'code' => 500,
'msg' => '订单创建失败,请稍后重试',
]);
}
// 4. 组装事件数据并触发事件(这是关键步骤)
$eventData = [
'order_id' => $orderId,
'order_no' => $orderNo,
'user_id' => $data['user_id'],
'amount' => $totalAmount,
'items' => $validItems,
'address_id' => $data['address_id'],
'created_at' => date('Y-m-d H:i:s'),
];
// 触发订单创建事件,框架会自动分发到所有监听器
Event::trigger(new OrderCreated($eventData));
// 5. 立即返回响应(异步监听器在后台队列中执行)
return json([
'code' => 200,
'msg' => '订单创建成功',
'data' => [
'order_no' => $orderNo,
'amount' => $totalAmount,
],
]);
}
/**
* 生成唯一订单号
*/
private function generateOrderNo(): string
{
return date('YmdHis') . strtoupper(substr(md5(uniqid(mt_rand(), true)), 0, 8));
}
}
核心代码在第4步——Event::trigger(new OrderCreated($eventData)) 这一行。当这行代码执行时,框架会:
- 查找
event.php中OrderCreated事件绑定的所有监听器。 - 依次执行同步监听器(如
DeductStockListener),它们的handle方法在当前请求进程中同步调用。 - 对于实现了
ShouldQueue的监听器,将其序列化后推入 Redis 队列,然后立即返回,不阻塞当前请求。
用户收到的响应中只包含订单创建结果,而短信通知、日志记录、统计更新都在后台悄然完成。
4.7 启动队列Worker
异步监听器被推入队列后,需要有Worker进程来消费。在项目根目录执行:
# 启动队列Worker,监听 default 队列
php think queue:listen
# 或者指定要监听的队列(多个队列用逗号分隔)
php think queue:listen --queue=order_notify,order_log,user_stat
# 生产环境建议使用 queue:work 配合 Supervisor 守护进程
php think queue:work --queue=order_notify,order_log,user_stat --daemon
开发阶段使用 queue:listen 即可,它会自动检测代码变更并重启。生产环境务必使用 queue:work --daemon 并搭配 Supervisor 保活,具体 Supervisor 配置如下:
[program:tp8-queue-worker]
command=php /path/to/your/project/think queue:work --queue=order_notify,order_log,user_stat --daemon
directory=/path/to/your/project
autostart=true
autorestart=true
startretries=3
user=www-data
numprocs=3
redirect_stderr=true
stdout_logfile=/var/log/supervisor/tp8-queue-worker.log
numprocs=3 表示启动3个Worker进程并行消费,可根据服务器CPU核心数和队列积压情况灵活调整。
五、进阶技巧与生产环境优化
5.1 事件订阅者模式简化管理
当系统中的事件和监听器数量膨胀时,使用订阅者可以将同一业务域的事件集中管理。在 app/subscribe 目录下创建 OrderSubscriber.php:
<?php
declare(strict_types=1);
namespace appsubscribe;
use appeventOrderCreated;
use appeventOrderPaid;
use appeventOrderCancelled;
class OrderSubscriber
{
/**
* 处理订单创建事件
*/
public function onOrderCreated(OrderCreated $event): void
{
// 处理逻辑
}
/**
* 处理订单支付事件
*/
public function onOrderPaid(OrderPaid $event): void
{
// 处理逻辑
}
/**
* 处理订单取消事件
*/
public function onOrderCancelled(OrderCancelled $event): void
{
// 处理逻辑
}
/**
* 注册订阅者监听的事件映射
*/
public function subscribe(): array
{
return [
OrderCreated::class => 'onOrderCreated',
OrderPaid::class => 'onOrderPaid',
OrderCancelled::class => 'onOrderCancelled',
];
}
}
然后在 event.php 的 subscribe 数组中注册即可。
5.2 事件数据尽量轻量化
事件对象在推入队列前会被序列化,如果事件中携带了大型对象(如完整的Model实例),不仅序列化开销大,还可能因为关联数据变更导致反序列化后的数据不一致。最佳实践是:事件中只携带必要的标量数据和数组,监听器内部按需从数据库获取最新数据。
5.3 队列失败处理与死信机制
ThinkPHP 8 的队列组件支持失败重试和失败回调。可以在监听器中定义 failed 方法来处理最终失败的情况:
public function failed(OrderCreated $event, Throwable $e): void
{
Log::critical("短信通知最终失败,订单号:{$event->getOrderNo()},错误:{$e->getMessage()}");
// 可以在这里触发钉钉告警、写入失败记录表等
}
当监听器重试次数耗尽后,failed 方法会被自动调用,这是最后一道防线。
5.4 幂等性设计
队列任务可能因为超时重试而被重复执行,因此监听器中的业务逻辑需要具备幂等性。例如,用户统计更新中使用 inc() 原子操作天然幂等;而短信发送则需要加上发送记录去重:
// 在发送短信前检查是否已发送过
$alreadySent = Db::table('sms_records')
->where('order_no', $orderNo)
->where('scene', 'order_created')
->find();
if ($alreadySent) {
Log::info("短信已发送过,跳过,订单号:{$orderNo}");
return;
}
六、总结与延伸思考
通过本文的完整实战,我们实现了以下架构改进:
- 核心链路精简:订单创建接口只做校验、落库和触发事件,响应时间从原本的1-2秒优化到200毫秒以内。
- 业务解耦:订单模块不再显式依赖短信服务、日志服务、统计服务,新增或移除监听器无需修改订单主流程代码。
- 弹性扩展:当短信通知量激增时,只需增加队列Worker进程数即可水平扩展,不影响订单服务本身。
- 故障隔离:短信网关宕机只会导致队列消息积压,用户的正常下单不受任何影响。
事件驱动架构并非银弹,它引入了异步带来的数据一致性问题(如库存扣减成功但短信发送失败后是否需要补偿),以及调试排错复杂度的提升。在实际项目中,需要根据业务场景合理评估:核心的、必须实时的操作保持同步;非核心的、允许延迟的操作走异步队列。
ThinkPHP 8 的事件系统与队列组件为PHP开发者提供了一套开箱即用的异步解耦方案。配合 Redis 队列和 Supervisor 守护进程,足以支撑日均十万级订单的中型电商系统。当业务进一步增长时,可以考虑将队列驱动切换为 RabbitMQ 或 Kafka,ThinkPHP 的队列抽象层使得这种迁移成本非常低。
建议读者在现有项目中寻找一个合适的切入点——比如用户注册后的欢迎邮件、内容发布后的搜索引擎推送——先从小场景开始实践事件驱动架构,逐步体会其带来的架构红利。
附:关键数据库表结构参考
为方便读者快速搭建测试环境,附上文中涉及的核心表结构:
-- 商品表
CREATE TABLE `products` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(200) NOT NULL,
`price` decimal(10,2) NOT NULL,
`stock` int unsigned NOT NULL DEFAULT '0',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 订单表
CREATE TABLE `orders` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL,
`user_id` int unsigned NOT NULL,
`address_id` int unsigned NOT NULL,
`total_amount` decimal(10,2) NOT NULL,
`status` tinyint NOT NULL DEFAULT '0',
`remark` varchar(500) DEFAULT '',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 订单商品明细表
CREATE TABLE `order_items` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`order_id` int unsigned NOT NULL,
`product_id` int unsigned NOT NULL,
`product_name` varchar(200) NOT NULL,
`price` decimal(10,2) NOT NULL,
`quantity` int unsigned NOT NULL,
`subtotal` decimal(10,2) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 用户统计表
CREATE TABLE `user_statistics` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`user_id` int unsigned NOT NULL,
`total_orders` int unsigned NOT NULL DEFAULT '0',
`total_amount` decimal(12,2) NOT NULL DEFAULT '0.00',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 操作日志表
CREATE TABLE `order_operation_logs` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL,
`user_id` int unsigned NOT NULL,
`action` varchar(50) NOT NULL,
`detail` text,
`ip_address` varchar(45) DEFAULT '',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

