Laravel 5.1 Eloquent ORM 随机返回错误的关联关系 - *重大更新*

31

我有一个Laravel应用程序,用于支持一个中等流量的电子商务网站。该网站允许人们通过前端下订单,但它还具备后端功能,可以通过呼叫中心接受电话订单。

订单与客户相关联,客户可以选择成为用户 - 用户是拥有前端登录帐户的人。没有用户帐户的客户仅会在通过呼叫中心接受订单时创建。

我遇到的问题非常奇怪,我认为可能是某种Laravel bug。

这种情况只会偶尔发生,但出现的问题是当通过呼叫中心接受无用户帐户的客户订单时,一份订单确认会被发送给一个随机的用户 - 很明显是随意从数据库中选择的用户,尽管数据中没有关联。

这些是项目模型的相关部分:

class Order extends Model
{
    public function customer()
    {
        return $this->belongsTo('App\Customer');
    }
}

class Customer extends Model
{
    public function orders()
    {
        return $this->hasMany('App\Order');
    }

    public function user()
    {
        return $this->belongsTo('App\User');
    }
}

class User extends Model
{ 
    public function customer()
    {
        return $this->hasOne('App\Customer');
    }
}

这些是上述数据库迁移的内容(为了简洁已编辑):

   Schema::create('users', function (Blueprint $table) {
        $table->increments('id');
        $table->string('first_name');
        $table->string('last_name');
        $table->string('email')->unique();
        $table->string('password', 60);
        $table->boolean('active');
        $table->rememberToken();
        $table->timestamps();
        $table->softDeletes();
    });

    Schema::create('customers', function(Blueprint $table)
    {
        $table->increments('id');
        $table->integer('user_id')->nullable->index();
        $table->string('first_name');
        $table->string('last_name');
        $table->string('telephone')->nullable();
        $table->string('mobile')->nullable();
        $table->timestamps();
        $table->softDeletes();
    });

    Schema::create('orders', function(Blueprint $table)
    {
        $table->increments('id');
        $table->integer('payment_id')->nullable()->index();
        $table->integer('customer_id')->index();
        $table->integer('staff_id')->nullable()->index();
        $table->decimal('total', 10, 2);
        $table->timestamps();
        $table->softDeletes();
    });

订单确认邮件的逻辑位于订单支付完成后触发的事件处理程序中。

这里是OrderSuccess事件(已编辑以缩短长度):

namespace App\Events;

use App\Events\Event;
use App\Order;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;


class OrderSuccess extends Event
{
    use SerializesModels;

    public $order;

    /**
     * Create a new event instance.
     *
     * @return void
     */
    public function __construct(Order $order)
    {
        $this->order = $order;
    }
}

可以看出,此事件传递了一个 Order 模型对象。

下面是事件处理程序(为简洁起见已编辑):

/**
 * Handle the event.
 *
 * @param  OrderSuccess  $event
 * @return void
 */
public function handle(OrderSuccess $event)
{
    // set order to paid
    $order = $event->order;
    $order->paid = date('Y-m-d H:i:s');
    $order->save();

    if(!is_null($order->customer->user)) {

        App_log::add('customer_order_success_email_sent', 'Handlers\Events\OrderSuccessProcess\handle', $order->id, print_r($order->customer, true).PHP_EOL.print_r($order->customer->user, true));

        // email the user the order confirmation
        Mail::send('emails.order_success', ['order' => $order], function($message) use ($order)
        {
            $message->to($order->customer->user->email, $order->customer->first_name.' '.$order->customer->last_name)->subject('Order #'.$order->id.' confirmation');
        });
    }

}

检查$order->customer->user对象是否为空,如果为真,则发送订单确认。如果它为空(它经常为空),则不发送确认。

从上面可以看出,我添加了一个记录对象的日志,当一封邮件被发送时。下面是一个错误示例(为简洁起见再次截断):

App\Customer Object
(
[attributes:protected] => Array
    (
        [id] => 10412
        [user_id] => 
        [first_name] => Joe
        [last_name] => Bloggs
        [telephone] => 0123456789
        [created_at] => 2015-09-14 13:09:45
        [updated_at] => 2015-10-24 05:00:01
        [deleted_at] => 
    )

[relations:protected] => Array
    (
        [user] => App\User Object
            (
                [attributes:protected] => Array
                    (
                        [id] => 1206
                        [email] => johndoe@whoknows.com
                        [password] => hashed
                        [remember_token] => 
                        [created_at] => 2015-09-19 09:47:16
                        [updated_at] => 2015-09-19 09:47:16
                        [deleted_at] => 
                    )
            )

    )

[morphClass:protected] => 
[exists] => 1
[wasRecentlyCreated] => 
[forceDeleting:protected] => 
)

App\User Object
(
[attributes:protected] => Array
    (
        [id] => 1206
        [email] => johndoe@whoknows.com
        [password] => hashed
        [remember_token] => 
        [created_at] => 2015-09-19 09:47:16
        [updated_at] => 2015-09-19 09:47:16
        [deleted_at] => 
    )

[morphClass:protected] => 
[exists] => 1
[wasRecentlyCreated] => 
[forceDeleting:protected] => 
)

正如你所看到的,Customer 没有 user_id,但 Laravel 返回了一个 User 对象。

此外,如果我手动触发完全相同的 OrderSuccess 事件,上述结果无法重现 - 它不会发送电子邮件,也不会加载 User 对象。

正如我之前所说,这个问题很少发生 - 每天通过呼叫中心为没有用户帐户的客户平均有约40个订单,而突出的问题可能只会发生一两次每周。

我不熟悉 Laravel,不知道这里可能是什么问题 - 是某种模型缓存、Eloquent ORM 的问题,还是系统中的其他小怪物?

请提供任何想法 - 如果它似乎是某种错误,我可能会在 Laravel github 问题跟踪器中发布此问题。

更新 关于一些提出的答案/评论,我已经尝试删除任何潜在的 Eloquent ORM 问题,手动检索数据,像这样:

$customer = Customer::find($order->customer_id);
$user = User::find($customer->user_id);

if(!is_null($user)) {
    // send email and log actions etc
}

上述代码仍会产生相同的随机结果——即使客户没有user_id(在此情况下为NULL),也会检索到不相关的用户。

更新2 由于第一个更新没有任何帮助,我回到使用原始的Eloquent方法。为了尝试另一种解决方案,我将我的事件代码从事件处理程序中拿出来,并将其放置在我的控制器中——我之前使用Event::fire(new OrderSuccess ($order));来触发OrderSuccess事件,现在我将这行注释并将事件处理程序代码放在控制器方法中:

$order = Order::find($order_id);

//Event::fire(new OrderSuccess ($order));

// code from the above event handler
$order->paid = date('Y-m-d H:i:s');
$order->save();

if(!is_null($order->customer->user)) {

    App_log::add('customer_order_success_email_sent', 'Handlers\Events\OrderSuccessProcess\handle', $order->id, print_r($order->customer, true).PHP_EOL.print_r($order->customer->user, true));

    // email the user the order confirmation
    Mail::send('emails.order_success', ['order' => $order], function($message) use ($order)
    {
        $message->to($order->customer->user->email, $order->customer->first_name.' '.$order->customer->last_name)->subject('Order #'.$order->id.' confirmation');
    });
}

以上更改已在生产网站上运行了一个多星期,自那时起,该问题再未发生。

我能得出的唯一可能结论是Laravel事件系统中的某种错误会破坏传递的对象。还是有其他原因?

更新3:似乎我过早地宣布将代码移动到事件之外解决了这个问题 - 实际上,通过我的日志,在过去的两天中,我可以看到又发送了一些不正确的订单确认(总共5个,在近3周没有问题之后)。

我注意到收到错误订单确认的用户ID似乎是递增的(不是连续的,但仍按升序)。

我还注意到,每个有问题的订单都是通过现金和账户信用支付的 - 大多数只是现金。我进一步调查发现,用户ID实际上是相关信用交易的ID!

以上是尝试解决此问题的第一次重大突破。经过仔细检查,我发现问题仍然是随机的 - 至少有50%的订单通过客户的帐户信用支付,但没有导致发送错误的电子邮件(尽管相关的信用交易ID与用户ID匹配)。

因此,问题仍然是随机的,至少看起来是这样。我的信用赎回事件触发方式如下:

Event::fire(new CreditRedemption( $credit, $order ));
上述内容是在我的“OrderSuccess”事件之前被调用的 - 你可以看到,这两个事件都会传递$order模型对象。 我的“CreditRedemption”事件处理程序如下所示:
public function handle(CreditRedemption $event)
{
    // make sure redemption amount is a negative value
    if($event->credit < 0) {
        $amount = $event->credit;
    }
    else {
        $amount = ($event->credit * -1);
    }

    // create the credit transaction
    $credit_transaction = New Credit_transaction();
    $credit_transaction->transaction_type = 'Credit Redemption';
    $credit_transaction->amount = $amount; // negative value
    $credit_transaction->customer_id = $event->order->customer->id;
    $credit_transaction->order_id = $event->order->id;

    // record staff member if appropriate
    if(!is_null($event->order->staff)) {
        $credit_transaction->staff_id = $event->order->staff->id;
    }

    // save transaction
    $credit_transaction->save();

    return $credit_transaction;
}
$credit_transaction->save();生成了一个在credit_transactions表中的ID,Laravel以某种方式使用该ID来检索用户对象。如上处理程序所示,我没有在任何时候更新我的$order对象。
Laravel如何使用(记住,仍然是随机的,有可能小于50%的时间)我新创建的$credit_transaction的ID来填充$order->customer->user模型对象?

1
另外,当您创建订单时,能否分享该特定代码? - codegeek
1
@BrynJ 有几个问题:您的事件监听器是否实现了“ShouldQueue”?您的事件是否使用了“SerializesModels”特质?如果在事件处理程序中,在检查“is_null”之前,您添加了“$ order-> load('customer.user');”会发生什么? - patricus
1
不久之前有一个错误,即使在5.0中已经修复,任何空的外键都会拉出随机记录。虽然不是理想的解决方案,但我想知道将所有空的外键设置为零是否可以解决这个问题? - Jeemusu
1
当Laravel检索关系时,它实际上并不关心外键的值是什么,它只执行查询。在您的情况下,当您的user_id = null时,会运行类似于此的查询:SELECT * FROM user WHERE user.id IS NULL尽管我真的不知道这将如何返回一个具有id!= NULL的用户,因为您的日志显示。除非有一些奇怪的情况,您的数据库刚刚创建了一个用户,但在查询运行时还没有创建自动增量id,然后在返回数据时生成了id。 - Arvid
1
需要检查的相关文件是vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.phpgetRelationshipFromMethod函数是用于获取关系的函数,以及vendor/laravel/framework/src/Illuminate/Database/Eloquent/Relations/BelongsTo.phpaddConstraints函数是用于设置关系约束的函数,即user.id IS NULL - Arvid
显示剩余24条评论
5个回答

3

我可以帮你找到问题根源,但是根据你在问题更新1中提供的逻辑,我可以提供一个可能的解决方法。

原始逻辑

$customer = Customer::find($order->customer_id);
$user = User::find($customer->user_id);

if(!is_null($user)) {
    // send email and log actions etc
}

修订逻辑

由于客户的user_id可能为空,限制返回具有user_id的客户可能更有效。这可以通过使用whereNotNull()方法实现。然后,我们可以继续检查是否返回了客户,如果是,则发送电子邮件等。

$customer = Customer::whereNotNull('user_id')->find($order->customer_id); 

if (!$customer->isEmpty()) { 
    // send email and log actions etc 
}

通过不让应用程序返回一个空的user_id,这样就有望解决您的问题,但不幸的是,它并没有揭示它首先发生的原因。


我已经授予你这个赏金,因为我相信它否则会根据某些标准自动授予,并且你的答案最接近解决方案/解决方法。自从大约两周前将代码移出我的事件处理程序以来,我没有遇到过这个问题 - 这只是再次确认了我对 Laravel 存在某种错误的信仰,可能与 SerializesModels 特性有关。我将把代码放回事件处理程序中,但删除此特性 - 因为我的事件不排队 - 并查看问题是否会再次发生。如果是,我将实施您的建议。 - BrynJ
我刚刚更新了我的问题,并发现了一个重大的新发现。 - BrynJ

1

我不确定你的迁移是否与模型定义正确匹配。如果你正在使用belongsTo和hasOne关系,应该在迁移中使用外键引用。

Schema::create('customers', function(Blueprint $table)
    {
        $table->increments('id');
        $table->integer('user_id')->nullable();
        $table->foreign('user_id')->references('id')->on('users');

        $table->string('first_name');
        $table->string('last_name');
        $table->string('telephone')->nullable();
        $table->string('mobile')->nullable();
        $table->timestamps();
        $table->softDeletes();
    });



Schema::create('orders', function(Blueprint $table)
{
   $table->increments('id');
   $table->integer('payment_id')->nullable()->index();

   $table->integer('customer_id')->nullable();
   $table->foreign('customer_id')->references('id')->on('customers');
   $table->integer('staff_id')->nullable()->index();
   $table->decimal('total', 10, 2);
   $table->timestamps();
   $table->softDeletes();
    });

现在,当创建客户记录时存在实际用户,您将需要设置此列。但是,您实际上不必手动设置此列。由于您正在使用关系,可以在下面执行以下操作:

步骤1:先保存客户。

$customer->save();

步骤二:如果存在用户,则将user_id设置为客户。为此,您可以在$user中获取用户对象,然后调用即可。
$customer->user->save($user);

上面的代码将自动在客户表上设置user_id。
然后我将按以下方式检查用户记录是否存在:
$user_exists = $order->customer()->user();

if($user_exists)
{
    //email whatever
}

1
以上内容符合应用程序的构建方式。然而,我从未阅读过实际上需要为Eloquent关系定义外键 - 你有任何参考资料可以指导我吗?显然,我知道什么是外键以及在级联删除和维护引用完整性方面在数据库级别上可能有多大用处,但不知道在应用程序级别上如何使用。 - BrynJ
这些引用键被索引了(我知道从我的迁移代码中不清楚),但它们并没有明确设置为外键 - 实际上,由于我们使用软删除操作,设置这些作为外键是否会直接带来好处还不确定? - BrynJ
请看我的最新问题更新 - 我已尝试完全从混合中删除 Eloquent 关系,但仍然偶尔会遇到这个问题。 - BrynJ
我相当确信不存在的 Eloquent 关系应该返回 null。现在我做的是将完全相同的代码块移出了事件,所以它现在只在控制器中。如果问题得到解决,我相信这表明了一个 Laravel 事件问题,否则问题必须在其他地方。 - BrynJ
将代码移出事件后,Eloquent关系仍然没有错误 - 这个更改已经上线了一个多星期。看起来越来越像是在将模型对象传递给事件时发生了某种随机模型损坏。 - BrynJ
显示剩余4条评论

1

你的迁移应该有吧

->unsigned()

for example:

$table->integer('user_id')->unsinged()->index();

如 Laravel 文档所述?

Laravel 还提供了创建外键约束的支持,用于在数据库层面强制实施引用完整性。例如,让我们在 posts 表上定义一个 user_id 列,该列引用 users 表上的 id 列。http://laravel.com/docs/5.1/migrations#foreign-key-constraints


1
我认为这个问题已经在其他地方得到了解决。实际上,在数据库级别上没有必要使用外键 - 实际上,Laravel支持的许多数据库引擎都不支持任何类型的外键。 - BrynJ
1
添加 ->unsigned() 只会增加整数字段的最大正数值(通过禁止负数并将所有 4 个字节分配给正数)。 - BrynJ

0

不必使用FK。 Eloquent可以根据列名建立关系。

更改字段名称。字段名称应与表名匹配,后缀为"_id"。在客户表中,user_id应该是users_id。在订单中,customer_id应该是customers_id。

您可以尝试传递要连接的字段的名称:

class Order extends Model
{
    public function customer()
    {
        return $this->belongsTo('App\Customer', 'foreign_key', 'id');
    }
}

我在Laravel方面不是很高级的用户,所以这可能不起作用。我遇到了同样的问题,并通过将所有模型和列重命名为与表名(带“s”)匹配来解决它。


感谢确认实际上不需要定义外键。"引用"键实际上是由模型名称确定的 - 因此,尽管我们有一个users表,但它是一个User模型,因此该键被命名为user_id。这里有一个参考链接 - http://laravel.com/docs/5.1/eloquent-relationships#one-to-one。我应该补充说明的是,关系在主要方面并不是问题 - 只是在这个非常罕见和随机的问题中出现了一些关系。 - BrynJ
你说得对,在你的情况下这不应该是问题。很抱歉我无法提供帮助。 - Yavor Atanasov
虽然您不需要定义外键,但对于大型应用程序而言,定义外键有助于加快搜索速度,因为外键会添加索引。 - Pistachio
@Pistachio - 链接键都已经建立索引,只是没有外键约束。因此性能是相当的(也许更高,因为永远不会发生级联操作)。 - BrynJ

0

你在 customers 表中的 user_id 字段应该可以为空。

$table->integer('user_id')->index()->nullable();

实际上这个应该设置为可为空,我会更新我的问题 - 我复制了最初的迁移,但还有一些其他的修改,其中包括添加了这个。 - BrynJ

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接