商品与计费
商品是本站对外销售的配置单元:在这里决定卖什么、卖多少钱、库存多少、是否自动开通。本文按「先建分组、再建商品、最后定价格与库存」的顺序讲,最后给出验证清单。所有结论来自当前代码实现,涉及字段的默认值与校验以代码为准。
1. 配置顺序
- 商品目录:建一级分类,再往下建二级、三级分组,商品挂在三级分组下。
- 商品类型:一级分类的
code与product_type决定商品属于云服务器、游戏云、CDN 还是虚拟主机等业务类型,它决定官网的入口分类,也决定用户控制台渲染哪套界面。 - 商品:填分组、规格、价格、配置项、库存。
- 计费周期与价格:至少给一个周期的价格,其余周期可让系统按比例推导(见第 4 节)。
- 折扣分组:需要给代理商分层定价时,先在「代理折扣」建「折扣组」,再回商品上绑定(见第 5 节)。
- 上游与库存:绑定供应商与上游商品,打开自动开通,库存交给定时同步(见第 6 节)。
- 上架:
status置为启用,官网才会销售。
2. 商品分组三级结构
官网按一级、二级、三级逐层下钻,商品只能挂在三级分组下。
| 层级 | 表 | 关键字段 | 作用 |
|---|---|---|---|
| 一级 | first_product_groups | code、product_type、name、icon、banner_image、is_visible、is_system | 官网顶层分类;code 参与推导商品业务类型 |
| 二级 | second_product_groups | first_product_group_id、name、is_visible | 导航中间层 |
| 三级 | third_product_groups | second_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 都是百分数):
折后金额 = 原价 × 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. 验证清单
- 官网:该商品能在正确的三级路径下找到,价格与周期标签与后台一致。
- 报价:登录状态下走一次报价,确认金额 = 周期价 + 配置项加价 + 初装费;代理账号再用一次,确认折扣率与折扣额符合预期。
- 库存:
stock = 0时下单被拒且提示明确;改回正数后立刻恢复。 - 下架:
status = 0后官网不再展示、新购被拒;已购用户的续费与控制台操作照常。 - 控制台:用测试账号买一台或打开一个已有实例,确认操作按钮与
console_template相符。 - 缓存:改完商品在 10 分钟内复查官网是否已更新,未更新则手动触发一次保存。