PAYATHON 2026

如何在 Rails4 中实现基于日期的自动操作

支付阿杰

结论

这类需求并不是“到了某天自动修改状态”,而是“执行操作时,根据当前日期判断是否允许”。比较合适的做法是把日期判断封装在模型中,再由控制器、服务对象或授权策略调用。付款、提交申请等重要操作还需要在服务端重新校验。

状态机适合描述明确的业务流程,例如“草稿 → 已提交 → 审核中 → 已通过”,但不适合单独处理付款开放时间、申请截止时间这类日期限制。两者可以配合使用,不过没必要为了判断日期而引入状态机。

在模型中集中定义时间规则

不要把日期比较分散在视图和控制器中。可以在模型里定义含义明确的查询方法:

class Campaign < ActiveRecord::Base
  validate :payment_date_range_is_valid
  validate :application_date_range_is_valid

  def payment_open?(on = Date.current)
    date_in_range?(on, first_payment_date, last_payment_date)
  end

  def application_open?(on = Date.current)
    date_in_range?(on, first_application_date, last_application_date)
  end

  def result_validation_open?(on = Date.current)
    date_in_range?(
      on,
      first_result_validation_date,
      last_result_validation_date
    )
  end

  private

  def date_in_range?(date, first_date, last_date)
    return false if first_date.blank? || last_date.blank?

    date.between?(first_date, last_date)
  end

  def payment_date_range_is_valid
    validate_date_range(
      :first_payment_date,
      :last_payment_date
    )
  end

  def application_date_range_is_valid
    validate_date_range(
      :first_application_date,
      :last_application_date
    )
  end

  def validate_date_range(first_attribute, last_attribute)
    first_date = public_send(first_attribute)
    last_date  = public_send(last_attribute)

    return if first_date.blank? || last_date.blank?
    return if first_date <= last_date

    errors.add(last_attribute, "不能早于开始日期")
  end
end

这段代码把开始日期和结束日期都算在有效期内。因此,last_payment_date 当天仍然可以付款,从第二天开始关闭。

如果结束日期当天也不能操作,可以改为:

first_date <= date && date < last_date

是否包含边界当天,应由业务规则明确规定,不能只根据字段名称判断。

在执行操作时检查,而不只是隐藏按钮

视图可以按照日期决定是否显示入口:

<% if @campaign.payment_open? %>
  <%= link_to "付款", new_campaign_payment_path(@campaign) %>
<% else %>
  <p>当前不在付款时间范围内。</p>
<% end %>

不过,隐藏按钮不等于完成了权限控制。用户仍然可以手工构造请求,所以控制器也必须检查:

class PaymentsController < ApplicationController
  before_action :set_campaign
  before_action :ensure_payment_is_open, only: [:new, :create]

  def new
    @payment = @campaign.payments.build
  end

  def create
    @payment = @campaign.payments.build(payment_params)
    @payment.user = current_user

    if @payment.save
      redirect_to @payment, notice: "付款记录已创建"
    else
      render :new
    end
  end

  private

  def set_campaign
    @campaign = Campaign.find(params[:campaign_id])
  end

  def ensure_payment_is_open
    return if @campaign.payment_open?

    redirect_to @campaign, alert: "当前不允许付款"
  end

  def payment_params
    params.require(:payment).permit(:amount)
  end
end

提交申请时可以采用同样的处理方式:

def ensure_application_is_open
  return if @campaign.application_open?

  redirect_to @campaign, alert: "当前不在申请开放时间内"
end

重要操作应在服务对象中重新校验

付款请求可能涉及第三方支付、余额扣减或订单创建。只在进入控制器时检查一次日期并不稳妥,因为从检查完成到实际写入之间,时间或数据都可能发生变化。

可以把操作及相关规则放入服务对象:

class CreatePayment
  class PaymentPeriodClosed < StandardError; end

  def initialize(campaign, user, attributes)
    @campaign = campaign
    @user = user
    @attributes = attributes
  end

  def call
    Campaign.transaction do
      @campaign.lock!

      unless @campaign.payment_open?
        raise PaymentPeriodClosed, "当前不允许付款"
      end

      @campaign.payments.create!(
        @attributes.merge(user: @user)
      )
    end
  end
end

控制器只处理请求和错误:

def create
  payment = CreatePayment.new(
    @campaign,
    current_user,
    payment_params
  ).call

  redirect_to payment, notice: "付款记录已创建"
rescue CreatePayment::PaymentPeriodClosed => error
  redirect_to @campaign, alert: error.message
end

是否需要数据库锁,要看具体的业务风险。普通内容提交可能用不到,但如果操作涉及付款、名额、库存,或者管理员可能同时修改截止时间,那么在事务中重新读取并校验会更可靠。

正确处理日期和时区

如果数据库字段是 date,通常应该使用:

Date.current

不要直接使用:

Date.today

Date.current 遵循 Rails 配置的时区,Date.today 使用服务器操作系统的日期。当服务器时区和业务时区不一致时,两者在午夜附近可能返回不同的日期。

如果字段是 datetime,则使用:

Time.current

例如:

def application_open?(at = Time.current)
  return false if application_starts_at.blank?
  return false if application_ends_at.blank?

  application_starts_at <= at && at <= application_ends_at
end

如果业务规则是“截止日期当天 23:59

前有效”,使用 date 字段通常比手工拼接一天的结束时间更清楚。如果必须精确到具体时刻,就应使用 datetime,并明确所采用的时区。

缺失日期的含义必须提前约定

上面的示例把任一边界为空解释为“当前不开放”。这种规则较为保守,但未必适合所有业务。

也可以作如下约定:

  • 开始日期为空:立即开放。
  • 结束日期为空:永久有效。
  • 两者都为空:不限制日期。

对应的实现如下:

def date_in_range?(date, first_date, last_date)
  return false if first_date.present? && date < first_date
  return false if last_date.present? && date > last_date

  true
end

具体采用哪种规则,需要由业务明确,并为每种组合编写测试,以免不同开发者对空值作出不同解释。

是否需要定时任务

如果需求只是“到期后禁止付款”,通常不需要定时任务。每次请求时计算 payment_open?,结果会直接取决于当前日期,也不会因定时任务延迟或失败而产生状态不一致。

只有到达指定日期后还要执行其他操作时,才需要考虑后台任务,例如:

  • 发送开放或截止通知;
  • 自动生成结算记录;
  • 取消未完成申请;
  • 将数据归档;
  • 调用外部系统。

即便使用了后台任务,付款接口仍应检查日期。后台任务不能取代操作入口处的业务校验。

状态机适合什么场景

状态机适合表达需要持久化的流程状态。例如,申请单可能包含以下状态:

draft -> submitted -> reviewing -> approved
                         |
                         -> rejected

此时,“提交申请”需要同时满足两个条件:

  1. 当前状态允许提交,例如 draft;
  2. 当前日期处于申请开放范围内。

概念上可以写成:

def can_submit?
  draft? && campaign.application_open?
end

状态机负责记录“业务进行到哪一步”,日期方法负责判断“当前是否处于允许操作的时间窗口”。除非这些状态需要审计、人工干预或触发其他流程,否则不要每天通过任务把记录从 before_application 改为 application_open,再改为 application_closed。这些状态只是对日期的重复记录,很容易和实际日期不同步。

具体选用哪个状态机 Gem,需要结合项目现有依赖和维护情况。Rails 4 项目通常年代较早,不宜照搬旧示例直接引入某个 Gem。单纯的日期限制不需要任何状态机依赖。

为边界条件编写测试

日期限制很容易在开始日、截止日和时区切换处出错。至少应覆盖以下情况:

describe "#payment_open?" do
  let(:campaign) do
    Campaign.new(
      first_payment_date: Date.new(2026, 9, 1),
      last_payment_date: Date.new(2026, 9, 10)
    )
  end

  it "在开始日期之前关闭" do
    expect(campaign.payment_open?(Date.new(2026, 8, 31))).to be false
  end

  it "在开始日期当天开放" do
    expect(campaign.payment_open?(Date.new(2026, 9, 1))).to be true
  end

  it "在结束日期当天开放" do
    expect(campaign.payment_open?(Date.new(2026, 9, 10))).to be true
  end

  it "在结束日期之后关闭" do
    expect(campaign.payment_open?(Date.new(2026, 9, 11))).to be false
  end
end

把日期作为参数传给判断方法后,测试时无需修改系统时间,这些规则也更容易复用。

比较稳妥的整体设计是:用日期字段保存配置,在模型方法中定义时间规则,由控制器或授权层拦截非法入口,再由服务对象在实际写入前重新校验。只有业务确实存在独立的流程状态时,才在此基础上加入状态机。

备注:内容仅供参考。