缺货的商品还在被 AI 推荐吗:Product availability 状态枚举的规范解读
适用读者:电商平台与品牌独立站的前端、后端、搜索技术负责人;正在做结构化数据(structured data)落地与 GEO 的同学;被客服工单推着回来查商品状态的运营同学。示例代码为 Python 与 JSON-LD,思路与语言无关。
一条客诉工单,把问题推到了结构化数据这里
工单原文是这么写的:在 AI 答问里搜「两千元以内的手持测振仪」,被推荐了 VM-820,下单三天后客服来电说这款停产了,让用户改型号。客服系统打的标签是「下单后告知无货」,同月这类工单 148 张,比上一季度翻了一倍多。

顺着商品页查下去,JSON-LD 里的 offers.availability 写的是 https://schema.org/InStock,而库存中心里这台货的 discontinued 标记一个月前就置成了 true。字段不是没写,是在模板里写死成一个常量,库存怎么变它都不动。
生成式引擎读到的就是这行常量,于是把一台停产设备在答问里当成可购买商品推给了用户。
结论先行
商品可用性(Product availability)不是一个装饰字段,它是 AI 答问引擎判断「能不能买」的主要依据,优先级高于正文里的「暂时缺货」小字提示。这事的复杂度不在协议本身——ItemAvailability 是 schema.org 定义好的一组枚举,写错值比不写更危险:写错时多数解析器会静默丢弃该字段,引擎拿不到任何可用性信号,只能退回按页面按钮文案猜,而页面上那个「加入购物车」按钮常年都在,猜出来的结果自然偏向可购买。
修法分三层:把枚举值写对并补全 URL;把库存状态通过可靠的链路同步进 JSON-LD,而不是写死在模板里;把非法枚举值的检测放进 CI,让它在合并前就拦下来。下面按这个顺序展开。
ItemAvailability 枚举全家桶:五个常用值之外的语义边界
ItemAvailability 是 schema.org 的枚举类型(Enumeration),实际取值是若干个以 URL 形式表达的枚举成员。多数团队只用到 InStock 与 OutOfStock,剩下的成员要么没听过,要么用错了地方。先把全家桶摆出来。
| 枚举值 | 语义 | 建议触发条件 | 与库存字段的关系 | 引擎的常见解读 |
|---|---|---|---|---|
InStock |
有货,可正常下单 | 可售库存大于水位阈值 | 可售库存为正 | 直接进可购买推荐池 |
LimitedAvailability |
库存有限 | 可售库存小于等于阈值 | 可售库存处于低位 | 可推荐,常附「库存不多」限定语 |
OutOfStock |
缺货,暂不可下单 | 可售为 0 且有补货计划 | 可售为 0,在途为正 | 不进推荐位或加「暂缺货」说明 |
BackOrder |
延期交货,可下单等待 | 可售为 0 但有在途采购 | 在途数量为正 | 可下单,需提示交付周期 |
PreOrder |
预售,未开售可预订 | presale 标记为 true | 开售日晚于当前日期 | 可预订,需带开售日 |
PreSale |
预购 | 与预售同场景的备选写法 | 同 PreOrder | 各引擎支持度不一致 |
SoldOut |
售罄,卖完不再补 | 实物与可售均为 0 | 与补货计划解耦 | 多被判为不可购买 |
Discontinued |
停产 | discontinued 标记为 true | 与库存数值无关的状态位 | 应移出推荐池 |
InStoreOnly |
仅门店有售 | 线上渠道不可售 | 门店库存为正 | 需配套门店信息 |
OnlineOnly |
仅线上销售 | 无线下渠道 | 与库存无关 | 常规在售 |
几个容易踩的点。Discontinued 与 OutOfStock 的区别不在库存数量而在生命周期:一台测振仪仓库里还剩两台样机,只要厂商停了产,就该写 Discontinued,写 OutOfStock 会让引擎理解为「等等还能买到」。SoldOut 是 schema.org 的合法成员,但 Google 商户列表的推荐值里收的是 OutOfStock,如果你的流量主要来自那边,用 OutOfStock 更稳。PreOrder 一定要配 availabilityDate,否则引擎拿到一个「可预订但不知道什么时候发货」的状态,只会选择不展示。
值有两种写法:全 URL(https://schema.org/InStock)与短名(InStock)。schema.org 官方文档明确建议写全 URL,短名会被引擎当作相对 URL 拼到当前域名后面,等于换了一个不存在的成员。大小写敏感,in_stock、IN_STOCK、数字 1 都不是成员。
stateDiagram-v2
[*] --> 在售: 上架且可售库存大于阈值
在售 --> 库存告急: 可售库存降到阈值以下
库存告急 --> 在售: 补货入库
在售 --> 缺货: 可售为 0 且有补货计划
缺货 --> 在售: 在途到仓
在售 --> 售罄: 可售与实物均为 0
在售 --> 停产: 厂商停止供货
售罄 --> 停产: 确认不再补货
在售 --> 预售: 未到开售日且开放预订
预售 --> 在售: 开售日到达
停产 --> [*]: 下架并移出推荐池
这张状态机图的作用是把业务状态和枚举值的对应关系固化下来。映射逻辑散落在各个服务里是这类问题反复复发的根源,集中成一个函数之后,枚举值只有一处出口。
原理与机制剖析:为什么写错的枚举值会静默失效
非枚举值被丢弃的机制
JSON-LD 的解析分两步:先按 JSON 做语法解析,再按 @context 把键值映射成 RDF 三元组。第一步只关心语法,任何合法 JSON 字符串都能通过;第二步才会检查值的类型是否与 schema.org 定义的 rangeIncludes 匹配。
offers.availability 的期望类型是 ItemAvailability。当解析器拿到字符串 in_stock 时,它既不是枚举成员的短名,也不是形如 https://schema.org/xxx 的 URL,无法解析成任何一个成员节点。这时候解析器有两个选择:抛错中断整段 JSON-LD 的解析,或者放弃这一个三元组继续处理其余字段。工程实现普遍选后者——一段商品页的结构化数据里还有价格、品牌、评分等十几个字段,为了一个可用性字段把整段丢掉不划算。
结果就是静默失败:页面照常渲染,富媒体测试工具给一条警告而不是错误,商品条目照常收录,唯独可用性这个槽位是空的。团队看着「无错误」的体检报告,以为字段写对了。
引擎拿到空槽位之后的决策链路
槽位为空不等于引擎会跳过这个商品。它还有页面正文、按钮文案、历史快照三条退路,而这几条退路的偏向性都很一致。
flowchart TD
A["商品页 HTML 中的 JSON-LD"] --> B{"解析 offers.availability"}
B -->|"值是合法枚举成员"| C["写入可用性槽位"]
B -->|"值是 in_stock 或 1 等非法值"| D["类型不匹配,槽位留空"]
B -->|"字段缺失"| D
C --> E["按状态过滤或附加限定语后输出"]
D --> F["降级:读取正文与按钮文案"]
F --> G{"正文含『加入购物车』或『立即购买』"}
G -->|"是"| H["判为可购买,进入推荐序列"]
G -->|"否"| I["降低置信度,倾向不推荐"]
H --> J["用户下单后才发现无货"]
I --> K["商品在答问中静默消失"]
这条链路解释了那个反直觉的现象:把 availability 写错,比把它删掉更糟。删掉时页面若没有购买按钮,引擎的降级判断可能给出不可购买;写错成 InStock 时,槽位虽然空了,但页面上的购买按钮还在,降级判断会往可购买那边倒。更麻烦的是按钮往往由前端按 SKU 状态渲染,而模板里的 JSON-LD 由后端拼,两者不同源,就会同时出现「按钮灰了但结构化数据写着有货」这类矛盾信号。
链路末端的两条分支对应两类损失:判为可购买会推高客诉与退款,判为不可购买会让商品在答问里静默消失。后者往往没人发现,因为它不产生工单,只是转化慢慢地少了一块。
动态库存同步到 JSON-LD:三种方案对比
把写死的枚举值换成动态值,工程上有三条路。
| 维度 | 模板渲染时注入 | 定时任务回写 | Redis 缓存加边缘注入 |
|---|---|---|---|
| 状态一致性 | 秒级 | 取决于调度周期,通常 5 到 15 分钟 | 秒级 |
| 对渲染耗时的影响 | 每个请求多一次库存查询 | 无 | 一次缓存读 |
| 实现成本 | 低 | 中 | 高 |
| 与 CDN 缓存的关系 | 整页缓存会把状态一起固化 | 需主动刷新静态片段 | 需分片或 ESI 支持 |
| 主要风险 | 库存服务抖动拖慢页面 | 闪断补货期间状态失真 | 缓存击穿与降级策略复杂 |
| 适用规模 | SKU 少、变更不频繁的站点 | SKU 十万级、日变更量可控 | 大促、秒杀等高频变更场景 |
方案一的做法是渲染请求进来时同步查一次库存服务,把返回值经映射函数写成枚举塞进 JSON-LD。一致性最好,代价是渲染路径上多了一个依赖,库存服务超时会直接拖慢商品页。库存查询本身要做本地短缓存与熔断,不能裸调。这个方案还有一个隐蔽的坑:商品页通常套了整页 CDN 缓存,缓存 10 分钟的话,注入得再实时也被缓存压回 10 分钟粒度。
方案二把状态计算搬到独立的 worker,按 SKU 批量算一遍枚举值,回写到数据库或静态片段里,渲染层只负责读。渲染路径干净,批量计算也方便做对账。延迟是它的固有成本,5 分钟周期意味着平均 2.5 分钟的状态滞后,闪断补货、限时放量这类分钟级变化会失真。周期压到 1 分钟以内时,批量查询对库存中心的压力就不小了。
方案三是把库存变更事件写进 Redis,渲染层或边缘节点读取。延迟和一致性都能做到秒级,也顺手解决了 CDN 缓存固化的问题,代价是要处理缓存击穿、Key 失效降级、版本号比对这些细节。大促期间值得投入,日常流量下维护成本偏高。
flowchart LR
subgraph P1["方案一 模板渲染时注入"]
A1["渲染请求"] --> A2["同步查询库存服务"] --> A3["拼 JSON-LD 并输出"]
end
subgraph P2["方案二 定时任务回写"]
B1["定时调度触发"] --> B2["批量计算枚举值"] --> B3["写回库表或静态片段"] --> B4["渲染层读取"]
end
subgraph P3["方案三 Redis 缓存加边缘注入"]
C1["库存变更事件"] --> C2["写入 Redis 并带版本号"] --> C3["渲染层或边缘节点读取"] --> C4["拼 JSON-LD 并输出"]
end
三条路并不互斥。常见组合是日常走方案二,大促期间把开关切到方案三,方案一作为方案三缓存全 miss 时的兜底路径。
落地代码:映射函数与同步脚本
下面的片段给出一份可运行的最小实现,包含映射函数、Redis 写入与渲染层拼装三段。
{
"@context": "https://schema.org",
"@type": "Product",
// 商品主体标识,与库存中心的 SKU 保持一致
"sku": "VM-820-B",
// 全球贸易项目代码,用于跨来源实体对齐
"gtin13": "06901234567890",
"name": "工业级手持测振仪 VM-820",
"brand": { "@type": "Brand", "name": "示例品牌" },
"offers": {
"@type": "Offer",
// 常见错误写法:枚举成员名必须严格匹配,不能自造
// "availability": "in_stock",
// 常见错误写法二:短名会被拼成当前域名下的相对 URL
// "availability": "InStock",
// 正确写法:补全 URL 的枚举成员
"availability": "https://schema.org/Discontinued",
// 预售场景必须给出开售时间,否则引擎倾向不展示
"availabilityDate": "2026-10-08",
"price": "2680.00",
// 币种必填,缺失时价格字段整体被判无效
"priceCurrency": "CNY",
// 价格有效期与可用性配套,过期后商品条目会降权
"priceValidUntil": "2026-12-31",
// 商品状况同样取自枚举,不要写「全新」这类中文值
"itemCondition": "https://schema.org/NewCondition",
// 卖家信息可选,多商家报价场景需要给出
"seller": { "@type": "Organization", "name": "示例商家" },
// 商品页地址,用于引擎回链与去重
"url": "https://example.com/p/vm-820"
},
// 停产商品建议同时移除 aggregateRating 之外的购买类字段
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.6",
// 评分人数必填,缺失时整段评分会被判无效
"reviewCount": "37"
}
}
映射函数这段是整套改造的核心,全站所有出口都调用它,避免枚举值在多个服务里各写一遍。
# 环境:Python 3.10 及以上,依赖 redis>=4.5(pip install redis)
# 作用:把库存中心的原始信号映射为 ItemAvailability 枚举,并写入缓存层
from __future__ import annotations
import json # 用于序列化写入缓存的载荷
import time # 生成版本号,便于比对渲染层是否读到旧值
from dataclasses import dataclass
from typing import Optional
import redis # 库存状态的高速缓存
# 枚举成员统一用 schema.org 全 URL,大小写敏感
AVAILABILITY = {
"InStock": "https://schema.org/InStock",
"LimitedAvailability": "https://schema.org/LimitedAvailability",
"OutOfStock": "https://schema.org/OutOfStock",
"BackOrder": "https://schema.org/BackOrder",
"PreOrder": "https://schema.org/PreOrder",
"SoldOut": "https://schema.org/SoldOut",
"Discontinued": "https://schema.org/Discontinued",
}
# 库存水位阈值,低于该值标记为库存有限,业务可按品类配置
LOW_STOCK_THRESHOLD = 10
@dataclass
class StockSignal:
# 库存中心推送的原始信号,字段缺失时取默认值
sku: str
# 实物库存:仓库里实际躺着的台数
on_hand: int = 0
# 可售库存:扣除锁定量与残次后的可承诺量
available: int = 0
# 在途库存:已下单采购但尚未到仓
in_transit: int = 0
# 停产标记,由商品生命周期系统维护
discontinued: bool = False
# 预售标记,与开售日配合使用
presale: bool = False
# 预售开售日,ISO 8601 日期字符串
release_date: Optional[str] = None
def map_availability(s: StockSignal) -> str:
# 决策顺序:停产 > 预售 > 断货 > 库存有限 > 有货
# 停产优先级最高,避免停产商品被当成可购买推荐
if s.discontinued:
return AVAILABILITY["Discontinued"]
# 预售商品尚未开售,不能写 InStock
if s.presale:
return AVAILABILITY["PreOrder"]
# 可售为 0 时区分「还能补」与「卖完了」
if s.available <= 0:
# 有在途库存,属于延期交货
if s.in_transit > 0:
return AVAILABILITY["BackOrder"]
# 在途也没有,判定为售罄
return AVAILABILITY["SoldOut"]
# 可售库存高于阈值,正常在售
if s.available > LOW_STOCK_THRESHOLD:
return AVAILABILITY["InStock"]
# 剩余不多,标记为库存有限
return AVAILABILITY["LimitedAvailability"]
def push_to_redis(client: redis.Redis, s: StockSignal, ttl: int = 900) -> None:
# 缓存键按 SKU 分片,避免全站共用一个 Key 形成热 Key
key = f"avail:{s.sku}"
# 载荷带上版本号与可售数量,便于排查延迟与脏读
payload = {
# SKU 用于回查与对账
"sku": s.sku,
# 枚举值由映射函数统一产出
"availability": map_availability(s),
# 可售数量留档,便于复核阈值是否合理
"available": s.available,
# 预售开售日,非预售场景为 None
"release_date": s.release_date,
# 版本号取写入时刻的秒级时间戳
"version": int(time.time()),
}
# 设置 TTL,消费端断连时状态也能在 15 分钟后自然过期
# 过期后渲染层走兜底路径同步查库存,避免永久脏值
client.set(key, json.dumps(payload, ensure_ascii=False), ex=ttl)
def read_from_redis(client: redis.Redis, sku: str) -> Optional[dict]:
# 读缓存,miss 时返回 None 由调用方降级
raw = client.get(f"avail:{sku}")
# 命中则反序列化,未命中直接返回空
return json.loads(raw) if raw else None
def render_offer(s: StockSignal, price: str) -> dict:
# 渲染层生成 Offer 节点,availability 只从映射函数取
offer = {
# 报价节点类型固定为 Offer
"@type": "Offer",
# 币种必填,缺失会让价格字段整体失效
"priceCurrency": "CNY",
# 价格由价格服务传入,不做本地计算
"price": price,
# 可用性状态,全站统一的产出出口
"availability": map_availability(s),
}
# 预售场景补开售日,给引擎一个明确的时间锚点
if s.presale and s.release_date:
offer["availabilityDate"] = s.release_date
# 停产商品不再给出价格有效期,避免价格信号继续生效
if s.discontinued:
offer.pop("price", None)
return offer
把非法枚举值拦在合并之前,比事后靠体检工具发现要便宜得多。下面这段放进 CI,跑在商品页渲染的单测里。
# 环境:Python 3.10 及以上,无第三方依赖
# 作用:断言渲染结果里的可用性字段一定是合法枚举成员
import unittest
# 与线上配置保持同一份白名单,改枚举时同步改这里
VALID = {
"https://schema.org/InStock",
"https://schema.org/LimitedAvailability",
"https://schema.org/OutOfStock",
"https://schema.org/BackOrder",
"https://schema.org/PreOrder",
"https://schema.org/SoldOut",
"https://schema.org/Discontinued",
"https://schema.org/InStoreOnly",
"https://schema.org/OnlineOnly",
}
class AvailabilityTest(unittest.TestCase):
# 用例一:停产标记优先于库存数量
def test_discontinued(self):
# 仓库里还剩三台,只要停产就应判为 Discontinued
s = StockSignal(sku="VM-820-B", available=3, discontinued=True)
# 断言输出为停产枚举
self.assertEqual(map_availability(s), "https://schema.org/Discontinued")
# 用例二:可售为 0 且有在途,应落到 BackOrder
def test_backorder(self):
# 可售清零,在途 12 台
s = StockSignal(sku="VM-820-B", available=0, in_transit=12)
# 断言延期交货而不是售罄
self.assertEqual(map_availability(s), "https://schema.org/BackOrder")
# 用例三:低库存场景应标记 LimitedAvailability
def test_limited(self):
# 可售数量为 2,低于阈值
s = StockSignal(sku="VM-820-B", available=2)
# 断言库存有限而不是有货
self.assertEqual(map_availability(s), "https://schema.org/LimitedAvailability")
# 用例四:预售场景落 PreOrder
def test_preorder(self):
# 预售标记为真,开售日在未来
s = StockSignal(sku="VM-821", presale=True, release_date="2026-10-08")
# 断言预售枚举
self.assertEqual(map_availability(s), "https://schema.org/PreOrder")
# 用例五:全量扫描,映射函数的任何输出都必须在白名单内
def test_all_outputs_valid(self):
# 遍历停产与预售两个开关
for discontinued in (True, False):
for presale in (True, False):
# 遍历边界值:零、一、阈值、阈值加一、极大值
for available in (0, 1, 10, 11, 999):
s = StockSignal(
sku="X",
available=available,
discontinued=discontinued,
presale=presale,
)
# 断言输出一定落在枚举白名单内
self.assertIn(map_availability(s), VALID)
if __name__ == "__main__":
unittest.main()
校验工具怎么报非法枚举值
线上体检用官方工具,本地拦截用上面的断言。两类工具的告警级别不一样,这是这类问题容易被放过去的关键:多数官方校验器把 availability 当成推荐字段而非必填字段,值非法时报警告,不影响富媒体结果的判定逻辑,体检报告看上去一片绿。
| 工具 | 入口 | 非法值提示形态 | 级别 | 是否阻断富媒体展现 |
|---|---|---|---|---|
| Schema Markup Validator | validator.schema.org | 值不在枚举范围内 | 警告 | 不阻断,字段被丢弃 |
| 富媒体结果测试 | search.google.com 的测试页 | 无效的枚举值 | 警告 | 非必填字段,不阻断 |
| Search Console 商户列表报告 | 后台报告页 | 可用性缺失或无效 | 错误 | 影响商品条目收录 |
| 自建 CI 断言与批量扫描 | 本文脚本 | 退出码非 0 | 阻断 | 合并前拦截 |
批量扫描线上页面的做法是把商品页 URL 列表灌进脚本,抽取 JSON-LD 后与白名单比对。
# 依赖:curl 7.68+、jq 1.6、Python 3.10;在 CI 容器里执行
# 1) 抓取商品页并抽出 JSON-LD 脚本块
curl -s "https://example.com/p/vm-820" \
| grep -o '<script type="application/ld+json">.*</script>' \
| sed 's/<[^>]*>//g' > /tmp/product.jsonld
# 2) 打印当前写入的 availability 原值,人工复核时先看这一行
jq -r '.offers.availability' /tmp/product.jsonld
# 3) 与枚举白名单比对,不在白名单内则以退出码 1 结束
# 说明:退出码非 0 时 CI 会阻断流水线
python - <<'PY'
import json, sys
# 读取上一步落盘的 JSON-LD 片段
data = json.load(open("/tmp/product.jsonld", encoding="utf-8"))
# 白名单与 CI 断言保持同一份,改枚举时两处同步
valid = {
# 有货与库存有限
"https://schema.org/InStock",
"https://schema.org/LimitedAvailability",
# 缺货与延期交货
"https://schema.org/OutOfStock",
"https://schema.org/BackOrder",
# 预售与售罄
"https://schema.org/PreOrder",
"https://schema.org/SoldOut",
# 停产
"https://schema.org/Discontinued",
}
# 取出实际写入的值,offers 可能是列表也可能是对象
offers = data.get("offers", {})
# 列表形态取第一个报价节点
if isinstance(offers, list):
offers = offers[0] if offers else {}
# 拿到 availability 原值
got = offers.get("availability")
# 值不合法或字段缺失都视为失败
if got not in valid:
print("非法的 availability 值:", got)
sys.exit(1)
print("availability 校验通过:", got)
PY
修复前后对照:客诉与推荐准确率
改造在一个有 4.2 万个 SKU 的工业品站点上跑了 30 天,采样口径统一为每周一上午抓取 200 个随机 SKU 的商品页,并统计同期客服工单。
| 指标 | 修复前 | 修复后(30 天) | 采集口径 |
|---|---|---|---|
| 停产或缺货商品被 AI 答问列为可购买 | 63 条 | 4 条 | 200 个抽样 SKU 的人工复核 |
| 与「下单后告知无货」相关的工单 | 148 单每月 | 21 单每月 | 客服系统标签统计 |
| 可用性字段合规率 | 31% | 97% | Schema Markup Validator 无警告的页面占比 |
| 非法枚举值条数 | 2180 | 0 | 全站 JSON-LD 扫描 |
| AI 答问给出正确库存状态的比例 | 41% | 92% | 抽样 SKU 在答问中的状态判定 |
| 加购后因缺货产生的退款率 | 6.8% | 0.9% | 订单库退款原因字段 |
工单下降主要来自停产与售罄两类商品被正确标注后移出推荐序列。剩余 4 条集中在定时任务还没跑到的新停产 SKU 上,把调度周期从 15 分钟压到 5 分钟后基本清零。
误区澄清
「只要页面上有缺货提示就够了」这条不成立。正文提示与结构化数据是两套信号,引擎优先读结构化数据,两者不一致时以前者为准。
「写 OutOfStock 就安全」也不成立。停产商品写 OutOfStock 会让引擎理解为等待补货,用户等来的是一通劝换型号的电话,这类情况要用 Discontinued。
「校验工具没报错就没问题」是最容易漏的一条。非法枚举值在官方工具里通常只是警告级别,字段被静默丢弃,报告照样显示结构化数据有效。真正的防线上移到 CI:availability 必须由一个集中映射函数产出,输出值必须落在白名单内,白名单与 schema.org 的枚举定义定期比对。
「库存同步一定要实时」要看场景。日常流量下 5 分钟的滞后对客诉的影响有限,为了秒级一致性上 Redis 加边缘注入,换来的是缓存击穿与降级策略的额外维护量。按变更频率选方案,比一上来就选最重的那个划算。
写代码的时候留个心:availability 是少数几个能直接影响用户是否被误导的字段之一,值得给它一个独立的映射函数、一组单测和一条 CI 断言。你们站点上的库存状态是怎么同步进 JSON-LD 的,遇到过哪些引擎解读与预期不一致的情况,评论区可以一起对一下。
参考与延伸
- ItemAvailability 枚举定义:https://schema.org/ItemAvailability
- Product 结构化数据字段说明(含 offers.availability):https://developers.google.com/search/docs/appearance/structured-data/product
- 结构化数据校验工具:https://validator.schema.org/
- JSON-LD 规范与
@context处理规则:https://www.w3.org/TR/json-ld11/
商品可用性 Product availability、ItemAvailability 枚举、JSON-LD 结构化数据、库存同步、GEO、AI优化AIO、电商搜索技术、库存状态映射