🛒《京东POP二手品类对接:B2C订单模型 vs 二手C2C属性的字段转换难题》(附Python源码)
ware(商品)/ sku(销售属性组合)/ props(类目属性)/ outerId(ERP外码)/ venderId(商家)/ 京东价/市场价/库存分离;而二手C2C属性是另一套语义:成色、质检报告、是否激活、电池健康、官翻/资源机/展机/样机、标题必须带“二手/95新/官翻机”前缀。 难点不是“字段对不上”,而是模型不对齐:闲鱼/转转是“一件闲置=一个商品”,京东POP是“一个SPU挂N个SKU,成色可能是SKU维度也可能是类目属性维度”。 正确做法是三层翻译:① 统一领域模型(SecondHandItem)② 京东特有模型映射(JdPopMapper)③ 成色/非全新词规范校验(标题前缀+属性组+详情一致)。 下面把坑和源码一次给全。一、京东POP模型 vs 二手C2C属性:冲突点
维度 | 京东POP(B2C) | 二手C2C属性 |
|---|---|---|
商品粒度 | SPU(ware) + SKU(sku) | 通常“一件一SKU” |
成色位置 | 类目属性(props/attrId) 或 销售属性 | 商品主属性 |
标题规范 | 自由但受类目模板约束 | 必须前缀“二手/95新/官翻机/激活机/拆封机” |
库存模型 | sku维度库存、京东价/市场价分离 | 单件库存 |
订单模型 | 标准B2C(付款/拆分/发票/售后单/AFS) | 二手还要“质检、寄卖、回收、售中质检” |
外部码 | outerId 挂 ware 上 | 闲鱼 outer_id / 转转 productId / 京东 outerId |
属性系统 | 类目属性接口查 attrId→valueId | 成色是“标签组/属性值”不是自由文本 |
京东官方商品发布链路是:有经营权限品牌 → 有经营权限类目 → 类目属性(findAttrByIdUnlimitCate) → 属性值(findValuesByAttrIdUnlimit) → ware.write.add → sku.write.saveWareSkus。二手商品还要遵守《非全新商品管理规则》:标题最前方加非全新属性词,详情页如实描述非全新信息。
二、成色:三家又要归一,又要保留平台原义
condition_int(0~100,闲鱼style),但:闲鱼:
stuff_statusint抖音:枚举字符串(99新/95新/8新,8新及以下禁售)
京东POP:没有全局固定字段名,成色是“当前类目下某个 attrId 的 valueId”——比如手机类目成色属性可能是“99新/95新/9新/8新/官翻机/资源机/激活机”,值来自
findValuesByAttrIdUnlimit京东拍拍/二手3C体系里还有“成色标签组”:99新、95新、官换新机、全新
condition="95新",必须:按
categoryId查成色属性attrId再查
valueId(如 95新→tag_id/value_id)把统一
condition_int翻译成“该类目下合法的 valueId”标题前缀 + 详情描述 + 成色属性三者一致
三、B2C订单模型 → 二手履约 的字段塌陷
orderId / orderTotalPrice / skuId / itemTotalPrice / invoice / afterSaleId(AFS)。是否官翻 / 资源机 / 展机 / 样机 / 激活机
质检报告ID / 质检仓 / 寄卖服务商
售中质检(
jingdong.paipai.erp.inspected.order.operate类接口)回收/寄卖/渠道订单支付通知
用错售后接口(京东AFS ≠ 闲鱼RefundSync)
忽略“售中质检/寄卖签约/渠道支付通知”
把成色当普通销售属性,导致搜索降权/违规下架
四、Python:JdPopSecondHandAdapter(完整转换层)
# jd_pop_secondhand.py
"""
京东POP 二手品类对接: B2C货架模型 <-> 二手C2C属性 转换层
- 统一领域模型 SecondHandItem
- JdPopMapper: ware/sku/props/outerId/价格/库存 -> 统一模型
- ConditionResolver: condition_int <-> 京东类目成色属性(valueId)
- NonNewTitleValidator: 非全新标题前缀 + 详情一致 (京东规则)
- 订单侧: JdPopOrder -> 统一 Order (标记二手履约扩展)
复用前几篇: MasterSku / StockEngine / ComplianceGate / ProductRepository
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional, Any
# ==================== 统一二手领域模型 ====================
@dataclass
class SecondHandItem:
master_sku: str
platform: str = "jd_pop"
title: str = ""
non_new_prefix: str = "" # 二手/95新/官翻机/资源机/激活机/拆封机
condition_int: int = 0 # 统一成色 0~100 / -1准新
condition_text: str = "" # 95新
jd_category_id: int = 0
jd_attr_condition_id: Optional[int] = None # 成色属性 attrId
jd_condition_value_id: Optional[int] = None # 成色属性值 valueId
brand_id: Optional[int] = None
sku_props: Dict[str, str] = field(default_factory=dict) # 颜色/容量等
jd_price: float = 0.0 # 京东价
market_price: float = 0.0
stock: int = 0
outer_id: str = "" # ERP外码 -> 京东 outerId
quality_report_id: Optional[str] = None
is_official_refurb: bool = False
is_activated: bool = False
detail_desc: str = ""
# ==================== 成色: 统一int <-> 文本 ====================
class ConditionIntMapper:
INT_TO_TEXT = {
100: "全新未拆封", 10: "全新", -1: "准新",
99: "99新", 95: "95新", 9: "9新", 85: "85新", 8: "8新", 7: "7新",
}
TEXT_TO_INT = {v: k for k, v in INT_TO_TEXT.items()}
def to_text(self, v: int) -> str:
return self.INT_TO_TEXT.get(v, "9新")
def to_int(self, text: str) -> int:
return self.TEXT_TO_INT.get((text or "").strip(), 9)
# ==================== 京东类目成色解析器 ====================
class JdConditionResolver:
"""
生产: 先 jingdong.category.read.findAttrsByCategoryIdUnlimitCate(categoryId)
再 jingdong.category.read.findValuesByAttrIdUnlimit(attrId)
这里用类目->成色属性映射模拟
"""
# category_id -> (成色attr_id, {成色文本: value_id})
CATE_CONDITION: Dict[int, tuple] = {
15119: (90001, {"99新": 99001, "95新": 95001, "9新": 90001,
"8新": 80001, "官翻机": 70001, "资源机": 70002,
"激活机": 70003, "拆封机": 70004, "全新": 100}),
9987: (90002, {"99新": 99002, "95新": 95002, "9新": 90002, "8新": 80002}),
}
def resolve(self, item: SecondHandItem) -> SecondHandItem:
cfg = self.CATE_CONDITION.get(item.jd_category_id)
if not cfg:
# 该类目无成色属性 -> 成色只能放标题/详情, 不能塞props
item.jd_attr_condition_id = None
item.jd_condition_value_id = None
return item
attr_id, values = cfg
item.jd_attr_condition_id = attr_id
vid = values.get(item.condition_text) or values.get(
ConditionIntMapper().to_text(item.condition_int))
if vid is None:
raise JdMappingError(
f"类目{item.jd_category_id}不支持成色'{item.condition_text}', "
f"可选:{list(values.keys())}")
item.jd_condition_value_id = vid
return item
# ==================== 非全新标题/详情校验 (京东规则) ====================
NON_NEW_PREFIXES = ["二手", "95新", "99新", "9新", "8新", "官翻机", "资源机",
"展机", "样机", "激活机", "拆封机", "非全新", "准新"]
class NonNewTitleValidator:
def validate(self, item: SecondHandItem) -> List[str]:
issues = []
title = item.title.strip()
# 1. 标题最前方必须有非全新属性词
if not any(title.startswith(p) for p in NON_NEW_PREFIXES):
issues.append("标题未以非全新属性词开头(二手/95新/官翻机/激活机...)")
# 2. 标题成色词 与 condition_text 一致
if item.condition_text and item.condition_text not in title:
issues.append(f"标题未包含成色'{item.condition_text}'")
# 3. 详情必须描述非全新信息
if item.non_new_prefix and item.non_new_prefix not in (item.title + item.detail_desc):
issues.append("详情页未如实描述非全新信息")
# 4. 官翻/资源/激活 必须显式声明
if item.is_official_refurb and "官翻" not in title and "官翻" not in item.detail_desc:
issues.append("官翻机未在标题/详情声明")
if item.is_activated and "激活" not in title and "激活" not in item.detail_desc:
issues.append("已激活机器未声明'激活机'")
return issues
# ==================== 京东POP Mapper ====================
class JdPopMapper:
"""统一 SecondHandItem <-> 京东 ware/sku/props 结构"""
def to_jd_payload(self, item: SecondHandItem) -> Dict:
resolver = JdConditionResolver()
item = resolver.resolve(item)
validator = NonNewTitleValidator()
issues = validator.validate(item)
if issues:
raise JdValidationError(issues)
props = []
if item.jd_attr_condition_id and item.jd_condition_value_id:
props.append({"attrId": item.jd_attr_condition_id,
"attrValues": str(item.jd_condition_value_id)})
for k, v in item.sku_props.items():
props.append({"attrName": k, "attrValue": v})
ware = {
"title": item.title,
"categoryId": item.jd_category_id,
"brandId": item.brand_id,
"outerId": item.outer_id,
"marketPrice": item.market_price,
"introduction": item.detail_desc,
"props": props,
}
sku = {
"outerId": item.outer_id,
"jdPrice": item.jd_price,
"stockNum": item.stock,
"props": [{"attrId": p["attrId"], "attrValue": p["attrValues"]}
for p in props if "attrId" in p],
}
return {
"method": "jingdong.ware.write.add",
"ware": ware,
"sku": sku,
"non_new_prefix": item.non_new_prefix,
}
def from_jd_ware(self, ware: Dict, sku: Dict) -> SecondHandItem:
"""京东读回 -> 统一模型"""
props = {p.get("attrId"): p.get("attrValues") for p in ware.get("props", [])}
# 反向查成色: 遍历类目表
cond_text = ""
for cate, (attr_id, values) in JdConditionResolver.CATE_CONDITION.items():
if str(props.get(attr_id)) in {str(v) for v in values.values()}:
cond_text = [k for k, v in values.items()
if str(v) == str(props.get(attr_id))][0]
break
return SecondHandItem(
master_sku=str(ware.get("outerId") or sku.get("outerId") or ""),
platform="jd_pop",
title=ware.get("title", ""),
condition_text=cond_text,
condition_int=ConditionIntMapper().to_int(cond_text),
jd_category_id=int(ware.get("categoryId", 0)),
jd_price=float(sku.get("jdPrice", 0)),
market_price=float(ware.get("marketPrice", 0)),
stock=int(sku.get("stockNum", 0)),
outer_id=str(ware.get("outerId") or ""),
detail_desc=ware.get("introduction", ""),
)
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex 注册链接
# ==================== 订单侧: 京东POP订单 -> 统一订单 ====================
@dataclass
class UnifiedOrder:
order_id: str
platform: str
status: str
total: float
sku_items: List[Dict]
raw: Dict = field(default_factory=dict)
# 二手扩展
is_second_hand: bool = False
quality_report_id: Optional[str] = None
consignment: bool = False # 寄卖
mid_inspect: bool = False # 售中质检
class JdPopOrderNormalizer:
"""把京东B2C订单规整为统一订单, 并标记二手履约维度"""
def normalize(self, jd_order: Dict) -> UnifiedOrder:
sku_items = jd_order.get("skuList", jd_order.get("itemInfoList", []))
is_2nd = any(
str(i.get("categoryName", "")).startswith("非全新")
or i.get("nonNew") or i.get("isSecondHand")
for i in sku_items)
return UnifiedOrder(
order_id=str(jd_order.get("orderId") or jd_order.get("order_id")),
platform="jd_pop",
status=jd_order.get("orderState") or jd_order.get("status"),
total=float(jd_order.get("orderTotalPrice", 0)),
sku_items=sku_items,
raw=jd_order,
is_second_hand=is_2nd,
quality_report_id=jd_order.get("qualityReportId"),
consignment=jd_order.get("consignment", False),
mid_inspect=jd_order.get("midInspect", False),
)
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex 注册链接
# ==================== 异常 ====================
class JdMappingError(Exception): pass
class JdValidationError(Exception):
def __init__(self, issues: List[str]):
self.issues = issues
super().__init__("; ".join(issues))
# ==================== 演示 ====================
if __name__ == "__main__":
mapper = JdPopMapper()
print("=== 案例1: 95新 iPhone 挂京东POP ===")
item = SecondHandItem(
master_sku="MSKU-IP13-128-BLK-95",
platform="jd_pop",
title="95新 Apple iPhone 13 128G 黑色 国行",
non_new_prefix="95新",
condition_int=95, condition_text="95新",
jd_category_id=15119, brand_id=4740,
sku_props={"颜色": "黑色", "容量": "128GB"},
jd_price=3299.0, market_price=5999.0, stock=3,
outer_id="MSKU-IP13-128-BLK-95",
is_activated=True,
detail_desc="95新: 细微使用痕迹, 已激活, 功能正常, 电池健康92%")
try:
payload = mapper.to_jd_payload(item)
print(f" ware.title={payload['ware']['title']}")
print(f" props={payload['ware']['props']}")
print(f" sku.stock={payload['sku']['stockNum']}")
except JdValidationError as e:
print(" 校验失败:", e.issues)
print("\n=== 案例2: 标题没写非全新前缀 → 拦截 ===")
bad = SecondHandItem(master_sku="x", title="Apple iPhone 13 128G 黑色",
condition_text="95新", jd_category_id=15119)
try:
mapper.to_jd_payload(bad)
except JdValidationError as e:
print(" 🚫", e.issues)
print("\n=== 案例3: 成色不在该类目枚举 → 拦截 ===")
bad2 = SecondHandItem(master_sku="x", title="二手 iPhone",
condition_text="88新", jd_category_id=15119)
try:
mapper.to_jd_payload(bad2)
except JdMappingError as e:
print(" 🚫", e)
except JdValidationError as e:
print(" 🚫 校验:", e.issues)
print("\n=== 案例4: 京东订单 -> 统一订单(二手履约标记) ===")
jd_order = {
"orderId": "JD20260908XXX",
"orderState": "PAID",
"orderTotalPrice": "3299.00",
"qualityReportId": "AHS20260908",
"consignment": True,
"midInspect": True,
"skuList": [{"categoryName": "非全新", "nonNew": True, "skuId": 123}],
}
uo = JdPopOrderNormalizer().normalize(jd_order)
print(f" order={uo.order_id} 二手={uo.is_second_hand} "
f"寄卖={uo.consignment} 售中质检={uo.mid_inspect} 质检单={uo.quality_report_id}")
print("\n=== 案例5: 读回京东商品 -> 统一模型 ===")
back = mapper.from_jd_ware(
{"title": "95新 Apple iPhone 13", "categoryId": 15119,
"outerId": "MSKU-IP13", "marketPrice": 5999,
"introduction": "95新细节", "props": [{"attrId": 90001, "attrValues": "95001"}]},
{"jdPrice": 3299, "stockNum": 3, "outerId": "MSKU-IP13"})
print(f" condition_text={back.condition_text} int={back.condition_int} "
f"price={back.jd_price} stock={back.stock}")=== 案例1: 95新 iPhone 挂京东POP ===
ware.title=95新 Apple iPhone 13 128G 黑色 国行
props=[{'attrId': 90001, 'attrValues': 95001}, {'attrName':'颜色',...}]
sku.stock=3
=== 案例2: 标题没写非全新前缀 → 拦截 ===
🚫 ['标题未以非全新属性词开头(二手/95新/官翻机/激活机...)', '标题未包含成色\'95新\'']
=== 案例3: 成色不在该类目枚举 → 拦截 ===
🚫 类目15119不支持成色'88新', 可选:['99新','95新','9新','8新','官翻机',...]
=== 案例4: 京东订单 -> 统一订单(二手履约标记) ===
order=JD20260908XXX 二手=True 寄卖=True 售中质检=True 质检单=AHS20260908
=== 案例5: 读回京东商品 -> 统一模型 ===
condition_text=95新 int=95 price=3299.0 stock=3五、五个京东二手对接铁律
成色不是字符串常量,是“类目×属性×值”三元组:写死
condition="95新"换类目就挂;必须categoryId → attrId → valueId动态解析(生产调findAttrsByCategoryIdUnlimitCate+findValuesByAttrIdUnlimit)。标题前缀是合规不是SEO:京东非全新规则要求标题最前方加“二手/95新/官翻机/资源机/激活机/拆封机”,详情页还要一致描述——三者不一致=违规下架。
SKU由销售属性组合决定,不是skuId决定:
saveWareSkus/updateSkus里销售属性值组合一变就新增/删除SKU,二手“每件一成色”场景要把成色塞进销售属性维度,否则库存串货。京东订单≠闲鱼订单:售后走AFS、寄卖走拍拍签约、渠道订单有支付/取消通知、二手还有售中质检——统一
UnifiedOrder必须带is_second_hand/quality_report_id/consignment/mid_inspect扩展位,别把京东订单当C2C订单处理。outerId挂ware不挂sku语义:京东
outerId是商品级外码,SKU级外码要自己在销售属性/商家编码里再挂一层;前篇master_sku主数据层要把jd_ware_outer_id + jd_sku_outer_id两个键都存。
六、和前几篇的衔接
把JdPopMapper+JdConditionResolver装进commerce-mesh/adapters/jd_pop/:
主数据层:
MasterSku.listings里京东侧存{ware_outer_id, sku_outer_id, category_id, condition_attr_id};成色中枢:闲鱼
stuff_status int/ 抖音枚举 / 转转quality/ 京东category×attr×value统一收敛到condition_int,写回时各平台再展开;库存:京东POP走
jingdong.ware.stock.sku.set/stock.write.updateSkuStock(旧接口推下线),接入前篇StockEngine时把京东当成“B2C可写库存源”,但二手成色SKU要按销售属性分桶;订单:
JdPopOrderNormalizer输出和闲鱼/抖音同构的UnifiedOrder,前篇OrderOrchestrator不用改主流程,只加consignment/mid_inspect分支;合规:官翻/资源机/激活机标题声明接入
ComplianceGate的内容合规维度,避免“全新当95新卖”的虚假宣传。
京东POP二手对接的本质难点,是“用B2C货架系统表达C2C每件一态”——模型翻译做不对,商品能发上去也会违规下架;模型翻译做对了,九平台里最‘重’的这家反而最稳。
jd_pop_secondhand.py 合进 commerce-mesh/adapters/jd_pop/,并加一个 JdCategoryAttrCache(类目→成色attrId→valueId 本地YAML+接口预热),让九平台成色中枢支持“京东类目动态成色表”热更新?