订单与自动开通
用户付款到拿到机器之间的链路:报价 → 建账单 → 生成内部订单 → 支付 → 上游开通 → 服务实例。本文按站长的排障顺序组织:先看清各状态的含义,再记住失败时钱和人分别停在哪里。用户侧的交易凭证是账单,订单是内部开通投影。
1. 链路与状态
报价(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 里的金额校验;对不上就报「订单配置与报价不一致,请重新获取报价」。
按顺序会拦下这些情况:
- 商品不存在或已下架 →「产品已下架」。
- 商品库存不足 → 见商品与计费第 6 节。
purchase_requires打开实名时未实名 →「该商品需要实名认证后才能购买」;打开手机号时未绑定 →「该商品需要绑定手机号后才能购买」。quantity不为 1 →「当前暂不支持一次购买多个服务实例,请分次下单」。多开必须分次下单,同一次请求下多台机器在这条链路上不成立。- 报价凭证无效或过期 →「报价凭证已失效,请刷新配置后重试」(有效期 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):
products.auto_setup = 1;- 商品已绑定供应商(解析出的供应商 ID 大于 0);
- 商品已绑定上游商品 ID;
- 该供应商插件声明支持开通能力。
任何一项不成立,服务会停在「开通中」并写入原因「待人工开通」,订单停在「开通中」。 最常见的一种:商品勾了「自动开通」,但没有绑定上游商品,用户付了钱机器不会自己出来。另一种是上游返回了非 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 落在目标日当天。
处理逻辑:
- 先按服务当前计费周期算出应收金额。
- 若这个到期日已经存在「用户已支付但未履约完成」的续费订单,跳过扣款并等它履约,避免重复建单。
- 余额不足或应收金额不大于 0 → 不扣款、不写已执行标记,下一次调度重试(表现为「余额补上后自动续费会自己成功」)。
- 余额充足 → 创建续费订单并用余额支付,然后走与手动续费相同的履约链路。
- 抛异常 → 写 error 日志、清除执行标记,等待重试。
同一服务在同一到期日只会被处理一次(执行日志的唯一规则键含到期日与提前天数)。余额不足、抛异常两种情况下标记会被清除,所以会反复重试而不是只试一次。
续费金额用的是服务上的锁定价格,不是商品当前价格。服务创建时写入 locked_pricing(开通时快照的各周期标准续费价,基准是未打折原价),续费时在此之上再叠加代理折扣与优惠券。这样做是为了避免代理用户每轮续费被二次叠加折扣。若 locked_pricing 为空,会按「续费基准原价 ÷ 当前周期月数」推导各周期价。所以改商品价格不会立刻影响老服务的续费金额;要让老服务按新价续费,需要单独调整服务的续费价格设置(该操作在后端支持,管理端入口未在代码中确认)。
到期前提醒在到期前 7 / 3 / 1 天各发一次邮件与站内信(这三个天数是代码内固定值 [7, 3, 1],后台配置里没有可改的入口)。自动生成续费账单在到期前 7 天窗口内为开启自动续费的服务建一张待付账单,由「账单自动化维护」定时任务执行。
7. 自动挂起与释放
四个开关都在后台 → 自动化策略(/admin/automation)页,默认值取自 SettingService::defaultAutomationConfig():
| 配置项 | 默认值 | 作用 |
|---|---|---|
expire_suspend_enabled | 开 | 到期自动暂停总开关 |
expire_suspend_after_days | 0 | 到期后多少天暂停;0 表示从到期次日起 |
expire_suspend_notify_enabled | 开 | 暂停后发通知(邮件 + 站内信) |
expire_terminate_enabled | 关 | 暂停后自动释放总开关 |
expire_terminate_after_days | 7 | 暂停后多少天释放 |
暂停(ServiceLifecycleAutomationService::suspendExpiredServices)的执行条件:expire_suspend_enabled = 1、服务状态为已开通、expires_at 早于「当前时间 − expire_suspend_after_days」。比较是严格小于,所以默认值下「到期当天」不会被暂停,从次日第一轮任务开始命中。
暂停时把服务状态改为已暂停、suspended_reason 写「已到期」、并在 provision_data.expired_suspended_at 记下暂停时刻。写之前会重新读一次状态与到期时间,刚续费成功(状态或到期时间已变)的服务不会被误暂停。
释放(cancelExpiredServices)分三步,且默认关闭:
- 时间预检:释放时刻 = 暂停时刻 +
expire_terminate_after_days(expire_suspend_after_days只在expired_suspended_at缺失时用于兜底推算)。未到点直接跳过,本轮不改任何状态。 - 调上游销毁实例(对齐魔方下游
Host::terminate协议)。销毁失败时保留暂停状态、下一轮重试,本地不做任何变更。 - 本地置为已取消并清空
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 节里所有「自动」都不会发生,而用户看到的现象是「已付款但一直开通中」。部署与调度配置见部署与调度。
联调与验证清单:
- 用一笔真实小额订单走通「下单 → 支付 → 自动开通 → 收到订购提醒」,确认订单落到「已完成」、服务落到「已开通」。
- 把商品的
auto_setup打开但不绑上游商品,付一笔,确认服务停在「开通中」且原因是「待人工开通」——这验证了人工兜底路径是否可见。临时改错上游凭据再付一笔(记得回滚),确认provision_error、suspended_reason、失败尝试记录三处都有内容。 - 把某个测试服务的
expires_at改到昨天,手工触发一次「服务生命周期维护」,确认它被暂停且suspended_reason为「已到期」。 - 建一张账单不管它,5 分钟后确认状态变已取消,且记录里能看到
schedule:invoice-cleanup。