Skip to content

商品与计费 ​

商品是本站对外销售的配置单元:在这里决定卖什么、卖多少钱、库存多少、是否自动开通。本文按「先建分组、再建商品、最后定价格与库存」的顺序讲,最后给出验证清单。所有结论来自当前代码实现,涉及字段的默认值与校验以代码为准。

1. 配置顺序 ​

  1. 商品目录:建一级分类,再往下建二级、三级分组,商品挂在三级分组下。
  2. 商品类型:一级分类的 code 与 product_type 决定商品属于云服务器、游戏云、CDN 还是虚拟主机等业务类型,它决定官网的入口分类,也决定用户控制台渲染哪套界面。
  3. 商品:填分组、规格、价格、配置项、库存。
  4. 计费周期与价格:至少给一个周期的价格,其余周期可让系统按比例推导(见第 4 节)。
  5. 折扣分组:需要给代理商分层定价时,先在「代理折扣」建「折扣组」,再回商品上绑定(见第 5 节)。
  6. 上游与库存:绑定供应商与上游商品,打开自动开通,库存交给定时同步(见第 6 节)。
  7. 上架:status 置为启用,官网才会销售。

2. 商品分组三级结构 ​

官网按一级、二级、三级逐层下钻,商品只能挂在三级分组下。

层级表关键字段作用
一级first_product_groupscode、product_type、name、icon、banner_image、is_visible、is_system官网顶层分类;code 参与推导商品业务类型
二级second_product_groupsfirst_product_group_id、name、is_visible导航中间层
三级third_product_groupssecond_product_group_id、name、is_visible商品实际归属层(products.product_group_id 指向这里)
  • is_visible = 0 会整层从官网隐藏。官网查询要求三级、二级、一级三层都可见,任何一层关掉,整条路径上的商品都不再出现在官网页面上。
  • 新增商品必须带 third_product_group_id,否则保存失败并提示「商品必须挂载到三级分组下」;二级分组缺少一级归属时提示「二级分组缺少一级归属」;显式传入的一级/二级 id 与三级分组的实际归属不一致时提示「一级分组与二级分组不匹配」。
  • 只填了一级、二级分组却没填三级分组就提交,是压缩站改版时最常见的失败点:商品不会创建,也不会有部分写入。
  • 商品类型不在商品行上直接选择,而是由一级分组的 product_type(或 code 经历史映射)推导后写入 products.product_type 与 service_type_code。因此「先建对一级分类,再建商品」不能颠倒;商品建好后再改一级分类的类型,会把该分类下所有商品的类型一起改写,官网入口与用户控制台模板都会跟着变。

3. 商品字段手册 ​

新增/编辑入口:后台 → 商品目录 → 新增商品,表单分「基础」「定价」「接口」「商品配置」等分区。

基础

字段必填默认影响
custom_display_name可选空商品对用户显示的名称。默认由系统填成规范规格摘要(如 2核2G、2核2G30Mbps),留空时按规格(CPU/内存等)自动生成;两者都取不到时显示「未配置规格 #<id>」。分组层级不在商品名里,由商品路径(product_full_path)单独展示——商品列表出现这个字符串,说明该商品的 config_options 或 purchase_requires.upstream_default_config 还没配好
third_product_group_id必填无决定商品挂在哪条官网路径下
console_template可选compute决定用户控制台渲染哪套界面:compute(云主机语义:电源、重装、VNC、安全组)、port_mapping(端口映射)、cdn(流量/加速区域/防护)、virtualhost(面板型托管)。传入不在枚举内的值时静默回落到 compute——配错的典型现象是「虚拟主机商品的控制台里出现了开关机按钮」
product_discount_group_id可选空代理折扣分组,见第 5 节
cpu_model / cpu_turbo可选空仅规格展示,不参与计价
description可选空商品详情富文本。落库前过白名单消毒,防注入,保存后前台直接渲染
remark可选空仅后台可见的内部备注
sort_order可选0同分组内排序;列表页可拖动排序
status可选1上架开关,见第 7 节

定价与库存

字段必填默认影响
pricing必填无计费周期价格,结构 { "monthly": "30.00", ... }
setup_fee可选0初装费,按每个实例计一次,直接加进账单金额
stock可选-1-1 表示不限量;见第 6 节
auto_setup可选0自动开通开关;详见订单与自动开通

购买条件与开通参数

字段默认影响
purchase_requires.require_verification关打开后未实名用户下单被拒:「该商品需要实名认证后才能购买」
purchase_requires.require_phone关打开后未绑手机用户下单被拒
purchase_requires.provision_hostname空(等同 system)开通时的机器命名规则:system 系统生成、fixed 固定主机名、prefix 字母前缀 + 随机后缀(length 限 4–63)。可在商品目录批量设置

setup_fee 虽然接口与数据库都支持(min:0),但后台商品编辑页并没有这个输入框,只能通过接口或其他商品批量入口写入。(未在管理端界面中确认存在设置初装费的入口)

4. 计费周期与价格 ​

系统支持的周期枚举(BillingCycle):monthly(月付)、quarterly(季付)、semiannually(半年付)、annually(年付)、biennially(两年付)、triennially(三年付)、one_time(一次性)、free(免费)、ontrial(试用,仅上游回传)。历史别名 yearly、onetime、biannually 会自动归一化。

  • 后台商品编辑页只提供月付、季付、半年付、年付四个价格输入框,其余周期不在界面上。需要更多周期时(未在管理端界面中确认入口)只能走接口。
  • 保存时系统会推导:缺少某一周期价格,且月付价格大于 0,则按「月付 × 周期月数」(1/3/6/12)自动补齐。月付留空或填 0,其余周期也不会自动生成。
  • 校验要求:pricing 必须是非空数组,且至少有一个周期金额大于 0,否则提示「请至少配置一个计费周期价格」。金额为 0 或负数的周期会被直接丢掉。
  • 读取价格时还有一层兜底:目标周期没有价格,就用「月付 × 月数」推算;月付也没有则金额记 0,此时下单会被拒(「无效的计费周期」)。
  • 账单金额 = pricing[周期] + 配置项加价 + setup_fee。加价明细与合计会写进账单的定价快照,改商品价格不会变动历史账单。

5. 配置项、加价与代理折扣 ​

配置项(config_options JSON):数组元素含 form_key、name、option_mode(select / range / text)、sub[](子项)。计价时的字段键优先取 form_key,没有才回退 field;两者都取不到时按 option_type 映射。这解释了为什么同一商品里有多个配置项时必须以 form_key 区分——全用 field 会互相覆盖。

  • option_mode = text 的提示项(例如「明确禁止」这类购买须知)只用于展示,不参与计价、也不写进配置快照。
  • hidden = 1 的配置项与子项不展示、不计价;用户没选的项不会产生加价。
  • range 型按 qty_minimum、qty_stage 与命中子项的边界算出「步数」,再乘该子项对应周期的单价。子项单价缺该周期时同样按「月付 × 月数」折算。
  • 提交值超出所有阶梯且没有「无上限」兜底段时,系统直接报「配置项「xxx」超出可选范围」并拒单,而不是按 0 元放过。

接了上游的商品:配置项加价走上游 /quote 实时报价,再按配置项上的 markup 套加价。markup.mode 四种:inherit(跟随上游,默认)、multiplier(上游价 × 倍率)、fixed(商家自定义单价 × 数量)、both(自定义单价 × 数量 × 倍率),三者都支持 extra_amount 附加费。

  • 切模式时已存的 multiplier / unit_price 不会被清空,但计价按当前 mode 显式分支,不会把上个模式的残留值算进去。
  • fixed 模式忘记填 unit_price 时会退化成上游成本价——表现为「自定义单价看起来生效了,实际加价是 0」。
  • 上游报价接口不可达时,退化为「只算后台设了 fixed/both 且填了单价的项」,其余项显示 0 元。

代理折扣:三层设置,从粗到细。

设置位置字段说明
折扣组后台 → 代理折扣name、min_discount_rate(默认 100)、cost_rate(默认 0)一组商品的分层定价底线与成本占比
商品绑定后台 → 商品目录 → 编辑商品product_discount_group_id把商品划进某个折扣组
代理组与矩阵后台 → 代理折扣default_discount_rate、矩阵行 discount_rate用户所属代理组的全局折扣率,以及按折扣组的例外值

计价规则(AgentDiscountService,discount_rate / cost_rate 都是百分数):

text
折后金额 = 原价 × discount_rate / 100
成本金额 = 原价 × cost_rate / 100

取值优先级:矩阵命中(用户代理组按该折扣组配了 discount_rate)→ 否则回退用户代理组的 default_discount_rate → 都没配则不打折(等于 100%)。代理组 status ≠ 1(停用)或商品没绑折扣组时,矩阵这条路直接不成立。

三道拦截必须知道:

  • discount_rate 必须在 0–100 之间,否则「折扣率必须在 0 到 100 之间」。
  • 矩阵折扣低于该折扣组的 min_discount_rate 时,「折扣率不能低于商品折扣组最低折扣率」——矩阵页面在保存前会先本地拦一道并列出具体是哪几格。
  • 折后金额低于 cost_amount 时,「代理折后金额不能低于成本价」。这是唯一会在下单时阻断用户的成本保护,配错 cost_rate(填太大)会让代理用户下不了单。

叠加顺序(CheckoutService::create):先按商品价格 + 配置项加价 + 初装费得到原价 → 套代理折扣 → 再扣优惠券。也就是优惠券作用在折后价上,实付 = 折后金额 − 优惠券抵扣,最低到 0。订单与账单会同时保存原价、代理折扣率、折扣额、成本额、优惠券快照,出争议时以快照为准。

6. 库存:本地值与上游真实值 ​

  • stock 的取值范围是 -1 及以上;-1 表示不限量,也是后台默认值。
  • 下游(魔方财务等)下单时读的是 products.stock;本站结账走的是「实时库存」,即 live_stock 优先、缺失才回落 stock。同一商品两处口径可能不同,排查「前台显示有货但下单失败」时要同时看这两个值。
  • 下单闸门在一次请求内完成「绑定商品 → 查上游库存 → 再判断」,判定只有一条:可用量不小于 0 且小于需求数量时抛「该商品库存不足,无法继续下单」。不限量(负值)不参与拦截。
  • 预扣只针对有限正库存:stock > 0 才按数量扣减,并把实际扣减量写进订单/账单快照的 stock_reserved。取消或退款按这个快照精确回补。不限量商品下单不扣、取消也不加回,因此不会因为「−1 卖出」而漂移。
  • 上游商品的库存由定时任务每 15 分钟同步一次,优先级依次是:从未同步过的商品 → 已售罄或余量吃紧(0 ≤ stock ≤ 5)→ 其他限量品 → 不限量品。不限量品永远排最后,因为它只是个固定标记。
  • 上游返回的库存经常不可信(代码注释明确记录了这一点)。系统还做了兜底:上游在结账时明确拒绝「库存不足/已下架」,本地库存快照会立刻归零并写一条过期覆盖,不必等下一次同步。
  • 站长的实际动作:不要把上游库存当精确账本。用「商品目录」的库存列看趋势,需要止损时直接改 stock(置 0 立即停售);手动改过的值会在下一次同步时被上游值覆盖。
  • 验证:找一个已上架、stock 至少为 1、已绑定供应商与上游商品并且 auto_setup 已打开的商品,用测试账号走到报价接口能返回正数金额、下单到建单不报库存错。这一步过了再谈放量。

7. 上下架对已购用户的影响 ​

  • status 只有 0 与 1。1 为在售。官网列表只取在售商品,官网缓存会在保存后清理。
  • 下架不会影响已购用户:服务实例、到期时间、控制台操作、工单都不受影响。
  • 下架会让新购报价与下单被拒(「产品已下架」)。续费链路与官网列表是两套取数逻辑,续费不检查商品上下架状态,所以老用户仍能续费;反过来,想靠下架来停止某商品的全部收入是不成立的,需要另行处理服务实例。
  • 删除商品是软删除,商品列表默认看不到,要切换到「已删除」生命周期筛选才能复核与恢复。
  • 彻底删除的代价:它会连带软删除该商品下的全部服务实例、把 orders.service_id 与 tickets.service_id 置空、删除 service_upstream_bindings 与 product_upstream_bindings。商品可以恢复,服务与上游绑定恢复不了。执行前先确认该商品下确实没有需要留存的实例。
  • 删除保护:商品下存在服务实例时,软删除与彻底删除都会被拒(「该商品已有服务实例,无法直接删除」),批量删除会跳过并在结果里返回原因(「存在服务实例」)。
  • 官网缓存延迟:商品类型与分组缓存 15 分钟,商品列表与详情缓存 10 分钟,库存字段单独走 10 秒缓存。后台改商品会主动清缓存,但直接改数据库、或走迁移脚本写入的数据不会清——改完没生效时,先在后台重新保存该商品或调一次排序,再刷新官网。

8. 验证清单 ​

  1. 官网:该商品能在正确的三级路径下找到,价格与周期标签与后台一致。
  2. 报价:登录状态下走一次报价,确认金额 = 周期价 + 配置项加价 + 初装费;代理账号再用一次,确认折扣率与折扣额符合预期。
  3. 库存:stock = 0 时下单被拒且提示明确;改回正数后立刻恢复。
  4. 下架:status = 0 后官网不再展示、新购被拒;已购用户的续费与控制台操作照常。
  5. 控制台:用测试账号买一台或打开一个已有实例,确认操作按钮与 console_template 相符。
  6. 缓存:改完商品在 10 分钟内复查官网是否已更新,未更新则手动触发一次保存。

关联文档 ​

基于 AGPL-3.0-or-later 发布