散装商品按克还是按斤:UnitPriceSpecification 单价结构化的架构设计与落地

2026-09-22 01:22:47 1 次浏览
GEOUnitPriceSpecificationSchema.orgPythonMySQLJSON-LD

也适合负责 GEO 的同学,需要知道单价字段该落在哪张表、哪个 JSON 节点,以及写了之后怎么验证真的被读到。 文里有 MySQL 8.0 建表、Python 3.11 批量生成脚本和两张数据流图,可以直接照着改。

用户问「这个多少钱」,AI 助手回了一句「29.9 元」。可我们那袋每日坚果的价签是「¥29.9/500g」,用户实际下单称重 748g,付了 44.75 元。差了将近一半。问题不在模型算错,在于我们把 29.9 原样塞进 Offer.price,却没告诉它这 29.9 到底是几个克的钱。

这类错在标品上不会出,一包 200g 固定规格的饼干,price 就是成交价,怎么读都对。散装商品不一样,价格的分子是钱、分母是重量,分母丢了,答案必然跑偏。

价签写的是 500g,AI 把它当成整份价

我们商品中心大约 4.6 万个 SKU,其中散装称重类 1.1 万个,集中在生鲜、坚果、冻品三条线。第 3 周灰度完结构化数据改造之后,客服那边转过来一批工单,话术都差不多:用户拿着 AI 给的报价来下单,发现对不上。

主题配图

复现方式很朴素。抓一个详情页 HTML,把 <script type="application/ld+json"> 里的文本抠出来看:

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "每日坚果 混合装",
  "offers": {
    "@type": "Offer",
    "price": "29.90",
    "priceCurrency": "CNY",
    "availability": "https://schema.org/InStock"
  }
}

站在解析器角度,这段数据能推出的结论只有一条:这个商品卖 29.90 CNY。它没有任何地方写着「29.90 是 500 克的价格」。模型要么照抄 price,要么凭商品名里的「500g」去猜,猜错了也不奇怪。

而生成式引擎优化(Generative Engine Optimization, GEO)在这件事上的处境有点尴尬:GEO 想让 AI 引擎引用你的数据,前提是这份数据自解释。价格字段自己说不清楚单位,再怎么优化文案也是白费劲。

先搞清楚 UnitPriceSpecification 规定了什么

UnitPriceSpecificationPriceSpecification 的子类型,专门用来描述「每单位数量的价格」。它挂在 Offer.priceSpecification 下面,本身不替代 Offer.price

字段 类型 作用 我们踩过的填法错误
price Number / Text referenceQuantity 配套的那笔钱 填了整份成交价,和基准量对不上
priceCurrency Text ISO 4217 货币码,如 CNY 写成「元」「¥」这类符号
referenceQuantity QuantitativeValue 价格的计数基准 直接写 500,单位信息全丢
referenceQuantity.value Number 基准数值 千克和克混用,0.5 与 500 写反
referenceQuantity.unitCode Text UN/CEFACT 代码 gG,正确是 GRM
referenceQuantity.unitText Text 给人看的单位文本 留空,页面价签和结构化数据各说各话
billingIncrement Number 最小计费步进 称重商品按 1 克计价却没标

unitCode 这块值得单独说。Schema.org 要求用 UN/CEFACT Recommendation 20 的三字母代码:重量用 GRM(克)、KGM(千克),体积用 MLT(毫升)、LTR(升),按件用 EA。写「g」「斤」「500克」都不合规,解析器遇到不认识的代码,最保守的做法就是整段忽略,等于白写。

unitText 不是冗余字段。它是给人和给检索片段看的,unitCode 是给机器做换算用的。两个都写,才不会出现「机器按克算、页面按斤显示」的错位。

解析机制:引擎怎么把 price 和 referenceQuantity 拼起来

底层其实是三步。第一步,解析器在 JSON-LD 里定位 Offer 节点,读 pricepriceCurrency,得到一个「数值 + 货币」的对子。第二步,如果 priceSpecification 里存在 UnitPriceSpecification,它会继续读 referenceQuantity,把数量抽成一个带单位的量。第三步,把这个量和价格绑定成比率,存进实体的价格属性。

关键在于第二步失败会怎样。多数解析器不会因为 referenceQuantityunitCode 就整页判废,它们会退化成第一步的结果——也就是把 price 当成整份成交价。这就是我们线上那个错误答案的来源:不是没抓,是抓到了一半。

还有一层,Offer 上同时有 pricepriceSpecification 时,不同引擎的取值优先级不一样。有的以 price 为准,有的优先信 priceSpecification。所以两者必须互相自洽:如果 price 放的是可成交价,priceSpecification 里的单价乘基准量就得回到同一个数量级,别让两份数据打架。

商品中心里单价字段怎么落

单价不能是页面模板里的一个字符串,得进库。不进库就没法批量校验,也没法在改价时自动重算。

我们的做法是新建一张独立的单价表,跟主 SKU 表按 sku_id 一对一,避免在 product 主表上继续堆列。

-- 环境:MySQL 8.0.35,InnoDB,utf8mb4
-- 用途:散装商品计价基准量表,页面的 JSON-LD 片段由它批量生成
CREATE TABLE product_unit_price (
  -- 商品中心 SKU 主键,与主表一对一
  sku_id            BIGINT         NOT NULL COMMENT '商品中心 SKU 主键',
  -- 价签金额,即 referenceQuantity 对应的那笔钱
  sale_price        DECIMAL(12,2)  NOT NULL COMMENT '价签金额',
  -- ISO 4217 货币代码,国内站点固定 CNY
  price_currency    CHAR(3)        NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217 货币代码',
  -- 计价基准数量,填 500 表示 500 个基准单位
  ref_qty_value     DECIMAL(12,3)  NOT NULL COMMENT '计价基准数量',
  -- UN/CEFACT 代码:GRM 克 / KGM 千克 / LTR 升
  ref_qty_unit_code VARCHAR(8)     NOT NULL COMMENT 'UN/CEFACT 单位代码',
  -- 人读单位文本,如 500g、0.5kg、1L
  ref_qty_unit_text VARCHAR(16)    NOT NULL COMMENT '人读单位文本',
  -- 折算到基准单位后的量,重量折克、体积折毫升,用于跨 SKU 比价
  base_qty_value    DECIMAL(16,6)  NOT NULL COMMENT '折算到基准单位后的数量',
  -- 基准单位代码,重量统一 GRM,体积统一 MLT
  base_unit_code    CHAR(3)        NOT NULL COMMENT '基准单位代码',
  -- 最小计费步进,按 1 克计价就写 1,可以为空
  billing_increment DECIMAL(12,3)  NULL     COMMENT '最小计费步进',
  -- 1 生效 0 下线,下线状态不生成结构化数据
  status            TINYINT        NOT NULL DEFAULT 1 COMMENT '生效状态',
  -- 更新时间,改价后靠它判断是否需要重算片段
  updated_at        DATETIME       NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  -- 一个 SKU 只留一条生效单价,重复写入直接报冲突,方便早发现
  PRIMARY KEY (sku_id),
  -- 单位代码必须落在白名单内,写「斤」或「g」会被拒绝入库
  CONSTRAINT chk_ref_unit CHECK (ref_qty_unit_code IN ('GRM','KGM','MLT','LTR')),
  -- 基准单位只认克和毫升,保证比价口径统一
  CONSTRAINT chk_base_unit CHECK (base_unit_code IN ('GRM','MLT')),
  -- 数量与价格都得是正数,负价或零量属于数据污染
  CONSTRAINT chk_positive CHECK (ref_qty_value > 0 AND sale_price > 0 AND base_qty_value > 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='散装商品单价基准量表';

base_qty_value 这一列是后来补的。第一版没有它,做跨 SKU 比价的时候,一个 SKU 按 500g 标、另一个按 1kg 标,程序得先把单位统一才能比,每次都临时换算又慢又容易错。加一列把折算结果存死,比价直接查就行。

ref_qty_valuebase_qty_value 的关系靠写入侧保证:运营后台保存时,服务层拿到 ref_qty_unit_code 查乘数表,算出 base_qty_value 一起写。数据库只做范围约束,不做换算,逻辑放在一处好排查。

从库表到 JSON-LD 的批量生成架构

生成是离线的,不在请求链路上做。详情页 QPS 高峰期上过 3 万,实时拼 JSON-LD 不划算,而且结构化数据本来也不需要秒级更新。

flowchart LR
  A["商品中心 MySQL<br/>product_unit_price"] --> B["单价归一服务<br/>换算到基准单位"]
  B --> C["JSON-LD 组装器<br/>Offer.priceSpecification"]
  C --> D["片段缓存 Redis<br/>key = sku_id"]
  D --> E["详情页渲染<br/>注入 ld+json 脚本块"]
  E --> F["AI 引擎与爬虫抓取"]
  F --> G["抽样校验与工单回流"]
  G --> B

那条回流的线是第 4 周才加的。一开始生成完就不管了,后来发现运营在后台把「500g」改成「0.5kg」,页面上 unitText 跟着变了,但有人在另一个系统里手工改了价,单价和基准量就对不上了。加了一道抽样校验,每天抽 800 个页面跑断言,报错直接进值班群。

生成脚本本身不长,麻烦的是单位换算和边界处理。

# -*- coding: utf-8 -*-
# 环境:Python 3.11.6 + PyMySQL 1.1.1,decimal 用标准库,无外部数值依赖
# 用途:从 product_unit_price 读单价,批量生成 Product 的 JSON-LD 片段

from decimal import Decimal, ROUND_HALF_UP
import json
import pymysql

# UN/CEFACT 代码折算到基准单位所需乘数
# 重量基准取克 GRM,体积基准取毫升 MLT,两条线各一个基准,互不混用
TO_BASE = {
    # 1 千克 = 1000 克
    "KGM": Decimal("1000"),
    # 1 克 = 1 克,无需换算
    "GRM": Decimal("1"),
    # 1 升 = 1000 毫升
    "LTR": Decimal("1000"),
    # 1 毫升 = 1 毫升,无需换算
    "MLT": Decimal("1"),
}

# 允许作为计价基准的单位,与数据库 CHECK 约束保持一致
ALLOWED_REF_UNIT = {"GRM", "KGM", "MLT", "LTR"}


# 把一行库记录折算到基准单位,返回基准数量
def normalize(row):
    # 取出单位代码,白名单外的单位一律不处理
    code = row["ref_qty_unit_code"]
    if code not in ALLOWED_REF_UNIT:
        # 宁可这条不生成,也不要产出脏数据
        raise ValueError(f"非法的计价单位: {code}, sku_id={row['sku_id']}")
    # 基准数量 = 标称数量 × 折算乘数
    base_value = Decimal(str(row["ref_qty_value"])) * TO_BASE[code]
    # 这里不做四舍五入,保留六位小数避免中间过程累积误差
    return base_value.quantize(Decimal("0.000001"))


# 构造一个 UnitPriceSpecification 节点
def build_unit_price(row):
    # 价格定到两位小数,银行家舍入改成四舍五入,跟财务口径一致
    price = Decimal(str(row["sale_price"])).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    # normalize() 去掉小数末尾多余的 0,500.000 会变成 500
    qty = Decimal(str(row["ref_qty_value"])).normalize()
    return {
        # 类型必须显式写 UnitPriceSpecification,写 PriceSpecification 不会被当单价
        "@type": "UnitPriceSpecification",
        # 只写数字不写货币符号,货币单独放 priceCurrency
        "price": str(price),
        "priceCurrency": row["price_currency"],
        "referenceQuantity": {
            # 基准量是个 QuantitativeValue,value / unitCode / unitText 三样齐全
            "@type": "QuantitativeValue",
            "value": str(qty),
            "unitCode": row["ref_qty_unit_code"],
            "unitText": row["ref_qty_unit_text"],
        },
    }


# 拼出完整 Product 节点
def build_product(row):
    # Offer.price 表示可成交价,priceSpecification 表示单价
    # 散装商品这两处写同一个数字,避免引擎取到互相矛盾的答案
    offer_price = Decimal(str(row["sale_price"])).quantize(Decimal("0.01"))
    return {
        # @context 固定写 https,写 http 也能解析但不规范
        "@context": "https://schema.org",
        "@type": "Product",
        # 商品名与页面 H1 保持一致,实体对齐靠它
        "name": row["sku_name"],
        # sku 用字符串,数字类型在部分解析器里会被当成 ID 丢精度
        "sku": str(row["sku_id"]),
        "offers": {
            "@type": "Offer",
            "price": str(offer_price),
            "priceCurrency": row["price_currency"],
            # 库存状态用 schema.org 的枚举 URL,不要写「有货」这种中文
            "availability": "https://schema.org/InStock",
            # 单价挂在这里,一个 Offer 可以有多个 priceSpecification
            "priceSpecification": build_unit_price(row),
        },
    }


def main():
    # 只读生效中的单价,下线的 SKU 不生成
    sql = """
        SELECT p.sku_id, p.sku_name, u.sale_price, u.price_currency,
               u.ref_qty_value, u.ref_qty_unit_code, u.ref_qty_unit_text
        FROM product_unit_price u
        JOIN product p ON p.sku_id = u.sku_id
        WHERE u.status = 1
    """
    # 只读账号连接商品中心从库,避免影响主库写入
    conn = pymysql.connect(host="127.0.0.1", user="geo_ro",
                           password="***", database="product_center",
                           charset="utf8mb4")
    # 成功与失败分开计数,最后一起打印
    ok = failed = 0
    with conn.cursor(pymysql.cursors.DictCursor) as cur:
        cur.execute(sql)
        for row in cur:
            try:
                # 先归一,非法单位在这一步就拦下,不进缓存
                row["base_qty_value"] = normalize(row)
                payload = build_product(row)
                # 写进 Redis 片段缓存,详情页渲染时直接取字符串
                save_fragment(row["sku_id"], json.dumps(payload, ensure_ascii=False))
                ok += 1
            except ValueError as e:
                # 单条失败不影响整批,记日志后继续跑
                log_error(e)
                failed += 1
    # 值班群里靠这个数字判断当天生成是否正常
    print(f"generated={ok} failed={failed}")

生成的片段长这样,关键是 referenceQuantity 里三样齐全:

{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "每日坚果 混合装",
  "sku": "10023871",
  "offers": {
    "@type": "Offer",
    "price": "29.90",
    "priceCurrency": "CNY",
    "availability": "https://schema.org/InStock",
    "priceSpecification": {
      "@type": "UnitPriceSpecification",
      "price": "29.90",
      "priceCurrency": "CNY",
      "referenceQuantity": {
        "@type": "QuantitativeValue",
        "value": "500",
        "unitCode": "GRM",
        "unitText": "500g"
      }
    }
  }
}

灰度三周里踩到的坑

第一周只放了 200 个 SKU 做验证,第二周铺到 6000 个,第三周全量。三周里出的问题集中在单位和数据同步两处。

单位这块最常见的错是「写了一半」。运营在后台填了 ref_qty_value = 500,单位下拉框选的还是上一版的「千克」,生成出来就是「29.9 元/500 千克」。库里有 CHECK 约束挡不住这种语义错,只能靠断言:如果折算后每 100 克的单价低于 0.01 元或高于 500 元,判为异常,进人工复核队列。这个区间是我们自己按品类标定的,冻品和坚果差别很大,别照抄。

第二个坑是四舍五入。sale_price 存两位小数,比价时算「每 100g 多少钱」会出现 5.9800000001 这种尾巴。我们统一在生成环节用 DecimalROUND_HALF_UP 定到两位,不在 SQL 里做 ROUND,因为 MySQL 的 ROUND 在不同版本对 .5 的处理细节有差异,踩过一次之后就挪到应用层了。

第三个坑跟 GEO 直接相关。改完之后我们用几个 AI 搜索入口复查,发现有些引擎会优先读 priceSpecification 里的 price,有些仍然读 Offer.price。只要两处一致就不会出现矛盾答案,所以我们定了条规矩:散装商品这两处必须写同一个数字,禁止一边写整份价一边写单价。

改造前后的可观测变化:

指标 改造前 改造后 采集方式
带单价结构化数据的 SKU 数 0 11240 库表 count
每周价格答错的客服工单 37 条 4 条 工单系统按标签统计
单价字段人工维护工时 每人每周约 6 小时 约 0.5 小时 运营排期表
抽样页面校验失败率 未采集 0.6% 每日抽 800 个页面跑断言
详情页渲染耗时 P99 41 ms 42 ms 网关埋点

耗时的变化可以忽略,因为片段是缓存好的,渲染只是多拼一次字符串。剩下 4 条工单查下来是用户对「500g 价」和「实付称重价」本身就有误解,属于展示问题,不是数据问题。

校验和改价后的同步

结构化数据写完就扔是最常见的掉链子方式。改价之后片段没重算,页面上的老价格能挂好几天。

sequenceDiagram
  participant O as 运营后台改价
  participant DB as product_unit_price
  participant S as 生成服务
  participant C as 片段缓存
  participant A as AI 引擎抓取
  O->>DB: 写入 sale_price 与 ref_qty_value
  DB-->>S: binlog 变更事件
  S->>S: 单位归一 + 区间断言
  S->>C: 覆盖该 sku_id 的片段
  A->>C: 详情页请求返回新片段
  A-->>A: 更新价格实体与引用

同步链路用的是订阅 binlog,不靠定时任务扫全表。全表扫一遍 4.6 万条要十几分钟,改价高峰期根本追不上;binlog 事件基本是秒级。

校验分三层。入库前由数据库约束挡住非法单位和非正数;生成时由脚本里的区间断言挡住语义错;生成后每天抽样 800 个页面,把 HTML 里的 JSON-LD 抠出来重新解析一遍,确认 unitCode 在白名单内、pricepriceSpecification.price 一致。三层加起来,剩下的就是运营填写本身的错了,只能靠培训。

跑断言的入口长这样,挂在定时任务里:

# 环境:Python 3.11.6,脚本位于 /opt/geo/tools/verify_ldjson.py
# 用途:抽样校验线上页面的 JSON-LD,异常退出码交给告警
# --sample 800 表示每天随机抽 800 个详情页
# --strict-unit 打开后,unitCode 不在 GRM/KGM/MLT/LTR 内直接判失败
# 退出码 0 全通过,1 存在失败样本,2 抓取本身出错(网络或超时)
python /opt/geo/tools/verify_ldjson.py --sample 800 --strict-unit
# 打印退出码,配合 cron 的邮件告警使用
echo "exit=$?"

误区澄清和往后怎么走

误区一:把 Offer.price 直接换成每克单价就完事。这会让「下单付多少钱」这件事彻底失去结构化表达,AI 答得出单价却答不出实付。正确的做法是 price 描述可成交价,priceSpecification 描述单价,两者数量级自洽。

误区二:以为写了 referenceQuantity 就一定被读到。如果 unitCode 不合规,解析器会静默忽略整段,页面上看起来一切正常,实际等于没写。这也是为什么我们把单位白名单做成了数据库约束,而不是靠文档约定。

往后看,AI 搜索入口对结构化数据的依赖只会更重。现在引擎还能靠商品名里的「500g」猜一猜,等商品库规模再往上翻,猜的成本就扛不住了,谁的数据自解释谁被引用。单价只是其中一个切口,QuantitativeValue 能挂的还有净含量、规格、包装数,建模思路是一样的:数值、单位代码、单位文本三样齐全,缺一样就可能被当成噪声丢掉。

你们那边散装商品的价格是怎么标的,是按 500g 还是按斤?评论区聊聊各自的处理方式。

参考与延伸

  • UnitPriceSpecification 官方定义与字段说明:https://schema.org/UnitPriceSpecification
  • QuantitativeValue 的 value / unitCode / unitText 用法:https://schema.org/QuantitativeValue
  • Offer 上 price 与 priceSpecification 的关系:https://schema.org/Offer

关键词:GEO、AI优化AIO、UnitPriceSpecification、Schema.org 结构化数据、商品被 AI 推荐、Python JSON-LD 生成、MySQL 商品建模

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。