Skip to content

订单与自动开通 ​

用户付款到拿到机器之间的链路:报价 → 建账单 → 生成内部订单 → 支付 → 上游开通 → 服务实例。本文按站长的排障顺序组织:先看清各状态的含义,再记住失败时钱和人分别停在哪里。用户侧的交易凭证是账单,订单是内部开通投影。

1. 链路与状态 ​

text
报价(quote_token,10 分钟有效)
   → 建账单 Invoice(用户侧凭证,due_date = 建单 + 7 天)
   → 生成影子订单 Order(内部开通链路)
   → 支付(网关回调或余额)
   → 上游开通 / 续费
   → 服务实例 Service + 账单 fulfillment_pending 清除

订单状态(OrderStatus):

值含义说明
0待付款可取消;续费影子订单与管理员挂账订单不受「超时清单一律关闭」影响
1已付款已收到钱,尚未开始开通
2开通中已提交上游或等待人工开通;卡在这个状态就是钱到了机器没到
3已完成服务已激活(或商品被判定为无需自动开通)
4已取消未付款超时或人工取消
5已退款已走退款流程

订单类型(OrderType):new(新购)、renew(续费)、upgrade(附加配置,含流量包与主机升级两种 kind)。

服务状态(ServiceStatus):0 开通中、1 已开通、2 已暂停、3 已到期、4 已取消。订单详情页是只读的:只有「查看账单」「查看用户」「查看服务」三个跳转,没有补单、重试、退款按钮,推进链路要走服务详情页与账单管理页(见第 5、7 节)。

后台入口:后台 → 全部订单(/admin/finance/orders)、普通订单、续费订单、附加配置、订单详情(/admin/finance/orders/:id)。账单侧在后台 → 账单管理,可取消未付账单。

用户侧:官网/用户入口 → 我的订单、我的账单(/client/orders、/client/invoices)、我的服务(/client/services)。

2. 下单时服务端校验什么 ​

建单入参只有这几项:product_id、billing_cycle、quantity、config、quote_token、user_coupon_id、幂等键。金额不由客户端决定:服务端按商品价格 + 配置项加价 + 初装费重算,再套代理折扣与优惠券,最后用 quote_token 里的金额校验;对不上就报「订单配置与报价不一致,请重新获取报价」。

按顺序会拦下这些情况:

  1. 商品不存在或已下架 →「产品已下架」。
  2. 商品库存不足 → 见商品与计费第 6 节。
  3. purchase_requires 打开实名时未实名 →「该商品需要实名认证后才能购买」;打开手机号时未绑定 →「该商品需要绑定手机号后才能购买」。
  4. quantity 不为 1 →「当前暂不支持一次购买多个服务实例,请分次下单」。多开必须分次下单,同一次请求下多台机器在这条链路上不成立。
  5. 报价凭证无效或过期 →「报价凭证已失效,请刷新配置后重试」(有效期 10 分钟);缺少幂等标识 →「请求缺少幂等标识,请刷新页面后重试」;计费周期无价格 →「无效的计费周期」。

防重复建单有三层:幂等键映射(有效期 15 分钟)、请求指纹(180 秒内同商品同周期同配置同优惠券复用同一张账单)、以及 invoices(user_id, idempotency_key) 唯一索引兜底。用户连点提交不会生成多张账单。

3. 支付与回调 ​

支付入口都是未认证路由(网关回调需要):

  • 支付宝:POST /api/v2/payment/alipay/notify,独立验签中间件 + 限流 60 次/分钟按 IP。
  • 其他网关:GET|POST /api/v2/payment/notify/{gateway},签名校验中间件 + 限流 60 次/分钟按 IP。

处理顺序是「先验签 → 再锁 payments 行 → 再幂等判断(已成功的直接返回成功)→ 事务内更新账单与订单投影」。改回调地址或密钥后必须重跑一次真实小额支付,因为验签失败在回调方看来和超时无法区分,会一直重试。支付渠道配置见支付渠道配置。

未支付账单与订单在建单后 5 分钟(paymentSessionTtlSeconds())被定时任务关闭,actor_name 记为 schedule:invoice-cleanup。但账单自身 due_date 是 7 天——7 天这里只是催收口径,不是付款窗口。

两类豁免这条 5 分钟规则,也正因为从未扣过库存,取消时不会回补库存:续费账单(type = renew)与其影子续费订单按 due_date 计费,否则续费提醒永远发不出去;管理员手动开通产生的挂账账单与订单(config_snapshot.admin_manual = true)。

4. 支付完成后的自动开通 ​

支付成功进入履约时,new 与 renew 类型在回调请求内同步执行,同步失败才回退到 provision 队列重试。队列任务 ProcessPaidOrderFulfillmentJob 的参数:最多 3 次尝试、单次超时 1200 秒、退避 30/120/300 秒、唯一键为订单 ID、WithoutOverlapping 防并发。

自动开通用四个条件同时成立才会真正提交上游(shouldAutoProvision):

  1. products.auto_setup = 1;
  2. 商品已绑定供应商(解析出的供应商 ID 大于 0);
  3. 商品已绑定上游商品 ID;
  4. 该供应商插件声明支持开通能力。

任何一项不成立,服务会停在「开通中」并写入原因「待人工开通」,订单停在「开通中」。 最常见的一种:商品勾了「自动开通」,但没有绑定上游商品,用户付了钱机器不会自己出来。另一种是上游返回了非 active 的状态,订单同样停在「开通中」,没有自动兜底重试,只能靠「用户产品状态同步」定时任务后续推进或人工处理(未在代码中确认该状态会自动把订单推进到「已完成」)。

5. 开通失败:现象、重试与人工重跑 ​

失败时写在哪里(排障时按这个顺序看):

位置内容
services.provision_data.provision_error失败原因(业务异常取原消息,其他异常统一为「上游开通失败,请检查供应商配置或日志」)
services.suspended_reason同一消息截断到 200 字,服务列表里能直接看到
services.provision_data.last_provision_attempt_at最近一次尝试时间
服务上游绑定表的开通尝试记录success / failed 与失败原因,含订单号、上游商品 ID,用于回溯每次提交
日志关键字 [自动开通] 支付后上游开通失败,含订单号、服务 ID、供应商 ID、上游商品 ID

失败后服务状态回到 0(开通中),订单保持 2(开通中)。

自动重试的边界:队列层 3 次尝试用尽后,failed() 钩子把已付未履约的新购账单标记 requires_refund = true 并写 error 日志。它只打标记,不自动退款——代码注释明确要求人工先与上游核实。标记的跳过条件是:订单类型不是 new、账单不是已支付、订单已完成。若失败原因是上游余额不足,会被识别出来单发通知,但订单不会暂停,需要人工充值后重跑。

人工重跑入口有两个,别用错:

入口位置行为
重新提交上游开通后台 → 用户管理 → 用户详情 → 服务实例管理 → 服务详情 →「手动开通」按钮(按钮仅在服务处于 0 开通中且 provision_error 非空时可用)调 retryFailedProvision(),重走一遍上游购物车:清空购物车 → 加入商品 → 结账 → 取实例 ID
关联上游实例同一入口的弹窗(标题「手动开通 / 关联上游」),填「上游实例 ID」把已有上游实例绑定到本地服务,不再向上游下单
命令行重跑服务器执行 php artisan provision:retry-failed默认 dry-run,只扫描并列印;加 --execute 才真重试;--limit=50、--max-retries=3

命令行重跑的「孤儿单」判定口径是三个条件同时成立:服务处于 0 开通中、订单处于 2 开通中、provision_data.provision_error 非空。达到 retry_count 上限(默认 3)的会被跳过并提示需人工介入。它会在失败时累加 provision_data.retry_count、last_retry_at、last_retry_error。

动手前必须先核实上游:上游开通流程是「清空购物车 → 加购 → 结账 → 取实例 ID」,没有上游幂等键回查。若上一次失败发生在「结账已成功、取实例 ID 之前」,直接重跑会再开出一台。所以先登录上游确认是否已存在该实例;已存在就用「关联上游实例」,不要再点重新提交。这条限制写在命令源码的注释里,也是它默认 dry-run 的原因。

钱已经收了但确实开不出来:账单被标记 requires_refund 后,人工核实再走退款。退款与库存回补的关系见商品与计费第 6 节。

开通失败的另一个常见原因是上游供应商侧的问题,凭据、连通性与上游商品绑定是否正常,见上游供应商对接。

6. 自动续费与到期提醒 ​

自动续费(AutoRenewService,定时任务「服务自动续费」)在到期前 3 天执行(取配置 idc.auto_renew_days_before,默认 3),扫描条件同时满足:services.auto_renew = 1、状态为已开通或已到期、expires_at 落在目标日当天。

处理逻辑:

  1. 先按服务当前计费周期算出应收金额。
  2. 若这个到期日已经存在「用户已支付但未履约完成」的续费订单,跳过扣款并等它履约,避免重复建单。
  3. 余额不足或应收金额不大于 0 → 不扣款、不写已执行标记,下一次调度重试(表现为「余额补上后自动续费会自己成功」)。
  4. 余额充足 → 创建续费订单并用余额支付,然后走与手动续费相同的履约链路。
  5. 抛异常 → 写 error 日志、清除执行标记,等待重试。

同一服务在同一到期日只会被处理一次(执行日志的唯一规则键含到期日与提前天数)。余额不足、抛异常两种情况下标记会被清除,所以会反复重试而不是只试一次。

续费金额用的是服务上的锁定价格,不是商品当前价格。服务创建时写入 locked_pricing(开通时快照的各周期标准续费价,基准是未打折原价),续费时在此之上再叠加代理折扣与优惠券。这样做是为了避免代理用户每轮续费被二次叠加折扣。若 locked_pricing 为空,会按「续费基准原价 ÷ 当前周期月数」推导各周期价。所以改商品价格不会立刻影响老服务的续费金额;要让老服务按新价续费,需要单独调整服务的续费价格设置(该操作在后端支持,管理端入口未在代码中确认)。

到期前提醒在到期前 7 / 3 / 1 天各发一次邮件与站内信(这三个天数是代码内固定值 [7, 3, 1],后台配置里没有可改的入口)。自动生成续费账单在到期前 7 天窗口内为开启自动续费的服务建一张待付账单,由「账单自动化维护」定时任务执行。

7. 自动挂起与释放 ​

四个开关都在后台 → 自动化策略(/admin/automation)页,默认值取自 SettingService::defaultAutomationConfig():

配置项默认值作用
expire_suspend_enabled开到期自动暂停总开关
expire_suspend_after_days0到期后多少天暂停;0 表示从到期次日起
expire_suspend_notify_enabled开暂停后发通知(邮件 + 站内信)
expire_terminate_enabled关暂停后自动释放总开关
expire_terminate_after_days7暂停后多少天释放

暂停(ServiceLifecycleAutomationService::suspendExpiredServices)的执行条件:expire_suspend_enabled = 1、服务状态为已开通、expires_at 早于「当前时间 − expire_suspend_after_days」。比较是严格小于,所以默认值下「到期当天」不会被暂停,从次日第一轮任务开始命中。

暂停时把服务状态改为已暂停、suspended_reason 写「已到期」、并在 provision_data.expired_suspended_at 记下暂停时刻。写之前会重新读一次状态与到期时间,刚续费成功(状态或到期时间已变)的服务不会被误暂停。

释放(cancelExpiredServices)分三步,且默认关闭:

  1. 时间预检:释放时刻 = 暂停时刻 + expire_terminate_after_days(expire_suspend_after_days 只在 expired_suspended_at 缺失时用于兜底推算)。未到点直接跳过,本轮不改任何状态。
  2. 调上游销毁实例(对齐魔方下游 Host::terminate 协议)。销毁失败时保留暂停状态、下一轮重试,本地不做任何变更。
  3. 本地置为已取消并清空 suspended_reason、移除 expired_suspended_at。若在上游销毁期间服务被续费恢复,本地跳过取消并写警告:[定时任务] 上游实例已销毁但本地服务状态已被并发变更,请人工核实——此时上游机器已经没了,必须人工补偿。

两个必须注意的判断点:

  • expire_terminate_after_days 的下限是 1(配置层会把它钳到不小于 1),计时起点是暂停时刻而不是到期时刻。所以「到期后 30 天才暂停 + 释放 7 天」=第 37 天释放,而不是第 7 天。
  • 若到期已久、远超 expire_suspend_after_days,expired_suspended_at 从未被写过时,兜底值取「expires_at + expire_suspend_after_days」,可能已经是过去时间。这意味着打开释放开关的那一轮,这些老服务会被立刻释放。开启前先核对现有已暂停服务的到期时间。

8. 调度依赖与验证 ​

以上自动化全部由调度心跳驱动,任务名与频率:

任务频率做什么
服务自动续费每 4 个心跳槽(约 15 分钟一槽)余额自动续费
服务生命周期维护每 15 分钟(可配)到期暂停、暂停通知、到期释放
账单自动化维护每小时(可配)续费提醒、自动建续费账单、账单到期提醒、逾期标记、续费履约补偿
账单与充值清理每 15 分钟(可配)关闭超时未付的账单、订单与充值单
用户产品状态同步每心跳槽拉上游实例状态回本地

心跳槽默认 15 分钟。心跳或队列不运行时,第 4、6、7 节里所有「自动」都不会发生,而用户看到的现象是「已付款但一直开通中」。部署与调度配置见部署与调度。

联调与验证清单:

  1. 用一笔真实小额订单走通「下单 → 支付 → 自动开通 → 收到订购提醒」,确认订单落到「已完成」、服务落到「已开通」。
  2. 把商品的 auto_setup 打开但不绑上游商品,付一笔,确认服务停在「开通中」且原因是「待人工开通」——这验证了人工兜底路径是否可见。临时改错上游凭据再付一笔(记得回滚),确认 provision_error、suspended_reason、失败尝试记录三处都有内容。
  3. 把某个测试服务的 expires_at 改到昨天,手工触发一次「服务生命周期维护」,确认它被暂停且 suspended_reason 为「已到期」。
  4. 建一张账单不管它,5 分钟后确认状态变已取消,且记录里能看到 schedule:invoice-cleanup。

关联文档 ​

基于 AGPL-3.0-or-later 发布