认证:OAuth 2.0 (Authorization Code Flow)
多语言:请求头 Accept-Language,且根据 locale 返回分类/尺码
分类树:categories 端点,locale 相关
尺码:catalog_sizes 按 catalog (gender) 分支
库存:单个 item 通常 quantity=1(一对一交易),库存同步语义不同于 SKU 模型
欧盟 GPSR 合规(2024年12月起):责任实体信息 + 产品安全声明
🇪🇺《Vinted + ERP 对接路径:欧洲二手时尚平台的库存同步与多语言合规》(附Python源码)
一、Vinted 对接的三条路径(先选对,再谈技术)
路径 | 适用 | 门槛 | ERP 角色 |
|---|---|---|---|
① 官方 Business API | 品牌方/专业卖家/认证服务商 | 申请准入(非自助注册) | 正规集成,全链路 |
② 受控 API 接入 | 特定合作方 | 受监督/受控 | 受限能力 |
③ 私有/非官方 | 灰色 | 违反 ToS 风险 | ❌ ERP 不应支持 |
关键认知:Vinted 不像国内平台那样"注册开发者即可调"——官方 Business API 是合作准入制,需要申请并被批准。 任何宣称"一键对接 Vinted"的第三方,都要先问清楚走的是哪条路径。路径③(非官方抓取/未授权调用)违反平台 ToS,ERP 必须在架构层明确拒绝支持(前篇ComplianceGate的红线)。
二、Vinted 与国内平台的核心语义差异
2.1 一对一交易 vs SKU 库存
国内 (闲鱼/拼多多): 一个SKU → 库存N件 → 扣减到0下架 Vinted (二手时尚): 一个Item → 库存1件 → 卖出即结束
StockEngine 是根本性的模型差异——不能把 Vinted 当 SKU 库存源。2.2 多语言 + 分类树 locale 驱动
请求头
Accept-Language: fr/de/nl/es...分类端点
categories按 locale 返回尺码
catalog_sizes按 catalog(性别/品类目录) 分支
一件"女装 M 码"在法国是Taille M、在德国是Größe M、在荷兰是Maat M——ERP 必须存"locale × catalog × 原始尺码",展示时按市场翻译,不能硬编码"M"。
2.3 欧盟 GPSR 合规(2024年12月起强制)
责任实体信息(Responsible Person / 经济运营者):名称、地址、邮箱
产品安全与合规声明:尤其针对非全新/翻新/含电池/电子类
可追溯:产品标识与合规文件
二手时尚相对低风险,但翻新/电子配件/化妆品/儿童用品品类仍触发 GPSR。ERP 必须在商品发布前校验责任实体字段 + 合规声明,否则下架。
三、Python:Vinted 合规适配层(完整源码)
# vinted_adapter.py
"""
Vinted × ERP 对接路径: 欧洲二手时尚
- 路径辨析: Business API(准入制) / 受控 / 拒绝非官方
- OAuth 2.0 (Authorization Code Flow) 凭证管理
- Locale驱动: 多语言分类 + 尺码(catalog分支)
- 一对一交易模型: 状态同步(非SKU扣减)
- GPSR合规: 责任实体 + 产品安全声明
- VintedAdapter 接入统一适配层 (MarketplaceAdapter Protocol)
复用前几篇: unified_adapter_layer / ComplianceGate / StockEngine
"""
from typing import Dict, List, Optional, Any
from dataclasses import dataclass, field
from enum import Enum
from abc import ABC, abstractmethod
# ==================== 接入路径 ====================
class VintedPath(Enum):
BUSINESS_API = "business_api" # 官方Business API (准入制)
CONTROLLED = "controlled" # 受控/受监督
UNOFFICIAL = "unofficial" # 非官方 (违规, 拒绝)
@dataclass
class PathDecision:
path: VintedPath
allowed: bool
reason: str = ""
class PathResolver:
"""对接路径辨析: 拒绝非官方路径"""
def resolve(self, has_business_agreement: bool, is_official: bool) -> PathDecision:
if not is_official:
return PathDecision(VintedPath.UNOFFICIAL, False,
"非官方/私有接入违反Vinted ToS, ERP拒绝支持")
if has_business_agreement:
return PathDecision(VintedPath.BUSINESS_API, True,
"官方Business API (准入制, 全链路)")
return PathDecision(VintedPath.CONTROLLED, True,
"受控API接入 (受限能力, 需监督)")
# ==================== OAuth 2.0 凭证 ====================
@dataclass
class VintedCredentials:
client_id: str = ""
client_secret: str = ""
access_token: str = ""
refresh_token: str = ""
token_type: str = "Bearer"
locale: str = "fr" # 市场: fr/de/nl/es/it/pl/cs
catalog: str = "women" # 品类目录: women/men/kids
def auth_header(self) -> Dict[str, str]:
return {"Authorization": f"Bearer {self.access_token}",
"Accept-Language": self.locale}
# ==================== Locale 多语言体系 ====================
class LocaleCatalog:
"""locale × catalog → 分类/尺码 映射"""
# 尺码按 catalog (性别/品类) 分支
SIZES: Dict[str, Dict[str, str]] = {
"women": {"XS": "34", "S": "36", "M": "38", "L": "40", "XL": "42"},
"men": {"S": "44", "M": "46", "L": "48", "XL": "50"},
"kids": {"2Y": "92", "3Y": "98", "4Y": "104"},
}
# 分类名按 locale (示例)
CATEGORIES: Dict[str, Dict[str, str]] = {
"fr": {"tops": "Hauts", "dresses": "Robes", "shoes": "Chaussures"},
"de": {"tops": "Oberteile", "dresses": "Kleider", "shoes": "Schuhe"},
"nl": {"tops": "Bovenkleding", "dresses": "Jurken", "shoes": "Schoenen"},
}
def translate_size(self, catalog: str, size: str, to_locale: str = None) -> str:
"""尺码标准化: 统一存EU码, 按locale展示"""
table = self.SIZES.get(catalog, {})
# 反向查: 输入可能是标签(S/M), 转EU码
eu = table.get(size) or size
return eu
def category_name(self, cat_key: str, locale: str) -> str:
return self.CATEGORIES.get(locale, {}).get(cat_key, cat_key)
# ==================== GPSR 合规 ====================
@dataclass
class GpsrDeclaration:
"""General Product Safety Regulation 合规声明"""
has_responsible_person: bool = False # 责任实体(欧盟内)
responsible_name: str = ""
responsible_address: str = ""
responsible_email: str = ""
safety_statement: str = "" # 产品安全声明
is_restricted_category: bool = False # 翻新/电子/儿童/化妆品
def validate(self) -> List[str]:
issues = []
if self.is_restricted_category:
if not self.has_responsible_person:
issues.append("受限品类(翻新/电子/儿童/化妆品)必须声明欧盟责任实体")
if not self.responsible_name:
issues.append("责任实体名称必填")
if not self.responsible_address:
issues.append("责任实体地址必填(欧盟内)")
if not self.safety_statement:
issues.append("必须提供产品安全声明")
return issues
# ==================== Vinted 领域模型 (一对一交易) ====================
@dataclass
class VintedItem:
"""Vinted listing: 一对一, quantity通常为1"""
master_sku: str
locale: str = "fr"
catalog: str = "women"
title: Dict[str, str] = field(default_factory=dict) # {fr:..., de:...}
description: Dict[str, str] = field(default_factory=dict)
category_key: str = ""
size: str = "" # 原始尺码标签 (S/M/XL)
eu_size: str = "" # 标准化EU码
price: float = 0.0
currency: str = "EUR"
brand: str = ""
condition: str = "good" # new_with_tags/like_new/good/satisfactory
quantity: int = 1 # ★ 通常为1 (一对一)
gpsr: GpsrDeclaration = field(default_factory=GpsrDeclaration)
status: str = "available" # available/sold/inactive
# ==================== 状态同步 (非SKU扣减) ====================
class VintedStockSync:
"""Vinted库存语义: 状态同步, 不是数量扣减"""
def on_sold(self, item: VintedItem) -> VintedItem:
"""卖出 → 状态= sold, 不从数量扣减"""
item.status = "sold"
item.quantity = 0
return item
def on_relist(self, item: VintedItem) -> VintedItem:
"""重新上架 (一对一可复用listing)"""
item.status = "available"
item.quantity = 1
return item
def to_unified(self, item: VintedItem):
"""Vinted item → 统一 StockChange (语义转换: 1件=1状态)"""
from unified_adapter_layer import StockChange
# Vinted的"可售"映射为统一库存的 available 二进制
return StockChange(
master_sku=item.master_sku, platform="vinted",
qty=1 if item.status == "available" else 0,
warehouse=f"VINTED_{item.locale.upper()}")
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
# ==================== Vinted Adapter (统一协议) ====================
class VintedAdapter:
"""符合 MarketplaceAdapter Protocol"""
@property
def platform(self) -> str: return "vinted"
def __init__(self, creds: VintedCredentials, path: VintedPath = VintedPath.BUSINESS_API):
self.creds = creds
self.path = path
self.locale = LocaleCatalog()
self._client = None # 生产: httpx/requests with auth_header
def publish_product(self, product: Any) -> Dict:
item = self._coerce(product)
# 1. GPSR校验 (受限品类)
issues = item.gpsr.validate()
if issues:
raise VintedComplianceError(issues)
# 2. 尺码标准化
item.eu_size = self.locale.translate_size(item.catalog, item.size)
# 3. 一对一模型强制 quantity=1
if item.quantity != 1:
raise VintedComplianceError(
[f"Vinted一对一交易, quantity必须为1 (got {item.quantity})"])
# 4. 组装 (生产: POST /api/v2/items)
return self._invoke("POST", "/items", {
"locale": item.locale, "catalog": item.catalog,
"title": item.title.get(item.locale, ""),
"description": item.description.get(item.locale, ""),
"category_id": item.category_key,
"size": item.eu_size, "price": item.price,
"brand": item.brand, "condition": item.condition,
"quantity": 1,
})
def sync_stock(self, change: Any) -> Dict:
# Vinted无SKU库存扣减 → 转状态同步
item = self._coerce(change)
if change.qty <= 0:
return self._invoke("PATCH", f"/items/{item.master_sku}",
{"status": "inactive"})
return self._invoke("PATCH", f"/items/{item.master_sku}",
{"status": "available", "quantity": 1})
def query_order(self, order_id: str) -> Optional[Any]:
raw = self._invoke("GET", f"/orders/{order_id}")
return self._to_order(raw)
def list_orders(self, since=None, **kwargs) -> List[Any]:
raw = self._invoke("GET", "/orders", {"status": kwargs.get("status")})
return [self._to_order(r) for r in (raw.get("items", []) or [])]
# ---- 多语言辅助 ----
def publish_multilang(self, item: VintedItem, locales: List[str]) -> Dict[str, Dict]:
"""同一商品发布到多个locale市场"""
results = {}
for loc in locales:
item.locale = loc
try:
results[loc] = self.publish_product(item)
except VintedComplianceError as e:
results[loc] = {"error": str(e)}
return results
# ---- 内部 ----
def _invoke(self, method: str, path: str, body: Dict = None) -> Any:
# 生产: 带 auth_header + Accept-Language 请求
return {"_mock": True, "platform": "vinted",
"method": method, "path": path, "body": body,
"headers": {"Accept-Language": self.creds.locale}}
def _coerce(self, obj: Any) -> VintedItem:
if isinstance(obj, VintedItem):
return obj
# 从统一 Product 转换 (简化)
return VintedItem(master_sku=getattr(obj, "master_sku", ""),
title={"fr": getattr(obj, "title", "")},
price=getattr(obj, "price", 0.0))
def _to_order(self, raw: Dict) -> Optional[Any]:
if not raw: return None
from unified_adapter_layer import Order, Money
return Order(order_id=str(raw.get("id", "")), platform="vinted",
status=raw.get("status", ""), raw=raw)
class VintedComplianceError(Exception):
def __init__(self, issues: List[str]):
self.issues = issues
super().__init__("; ".join(issues))
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
# ==================== 演示 ====================
if __name__ == "__main__":
print("=" * 60)
print("Vinted × ERP 对接路径: 欧洲二手时尚")
print("=" * 60)
print("\n=== 1. 路径辨析 ===")
resolver = PathResolver()
for has_biz, official, tag in [
(True, True, "品牌方(准入)"), (False, True, "合作方(受控)"),
(False, False, "非官方(违规)")]:
d = resolver.resolve(has_biz, official)
print(f" [{tag}] {d.path.value}: allowed={d.allowed} - {d.reason}")
print("\n=== 2. 多语言分类 (locale驱动) ===")
lc = LocaleCatalog()
for loc in ["fr", "de", "nl"]:
print(f" {loc}: dresses → {lc.category_name('dresses', loc)}")
print(f" 尺码 women S → EU {lc.translate_size('women', 'S')}")
print("\n=== 3. GPSR 合规校验 (受限品类) ===")
gpsr_bad = GpsrDeclaration(is_restricted_category=True) # 翻新/电子
print(f" 不合规项: {gpsr_bad.validate()}")
gpsr_ok = GpsrDeclaration(is_restricted_category=True,
has_responsible_person=True,
responsible_name="EU Rep SARL",
responsible_address="Paris, France",
safety_statement="符合GPSR要求")
print(f" 合规项: {gpsr_ok.validate()}")
print("\n=== 4. 一对一交易模型 (quantity必须为1) ===")
creds = VintedCredentials(access_token="tok", locale="fr", catalog="women")
adapter = VintedAdapter(creds, VintedPath.BUSINESS_API)
item = VintedItem(master_sku="VIN-001", locale="fr", catalog="women",
title={"fr": "Robe Zara 38"}, description={"fr": "Très bon état"},
category_key="dresses", size="M", price=25.0, brand="Zara",
condition="good",
gpsr=GpsrDeclaration(is_restricted_category=False))
try:
r = adapter.publish_product(item)
print(f" 发布: {r['body']['quantity']} (一对一, quantity=1)")
except VintedComplianceError as e:
print(f" 拦截: {e}")
print("\n=== 5. quantity≠1 → 拦截 ===")
item_bad = VintedItem(master_sku="VIN-002", quantity=50, # SKU思维, 违规
title={"fr": "Lot"}, gpsr=GpsrDeclaration())
try:
adapter.publish_product(item_bad)
except VintedComplianceError as e:
print(f" 🚫 {e}")
print("\n=== 6. 多语言发布 (同一商品 × 多市场) ===")
item.title = {"fr": "Robe Zara 38", "de": "Zara Kleid 38", "nl": "Zara Jurk 38"}
item.description = {"fr": "Très bon état", "de": "Sehr gut", "nl": "Zeer goed"}
results = adapter.publish_multilang(item, ["fr", "de", "nl"])
for loc, r in results.items():
print(f" [{loc}] {r}")
print("\n=== 7. 库存=状态同步 (非SKU扣减) ===")
sync = VintedStockSync()
sold = sync.on_sold(item)
print(f" 卖出后: status={sold.status} quantity={sold.quantity}")
unified = sync.to_unified(sold)
print(f" → 统一StockChange: qty={unified.qty} warehouse={unified.warehouse}")
print("\n=== 8. 接入统一适配层 ===")
try:
from unified_adapter_layer import AdapterRegistry, MarketplaceService
reg = AdapterRegistry()
reg.register(adapter, "default", ["publish", "stock", "order"])
svc = MarketplaceService(reg)
print(f" 已注册: {reg.all_platforms()}")
ch = StockChange(master_sku="VIN-001", platform="vinted", qty=0)
print(f" 统一sync_stock(卖出→inactive): {svc.sync_stock('vinted', ch)}")
except ImportError:
print(" (unified_adapter_layer 未在本环境, 逻辑验证跳过)")=== 1. 路径辨析 ===
[品牌方(准入)] business_api: allowed=True - 官方Business API (准入制, 全链路)
[合作方(受控)] controlled: allowed=True - 受控API接入 (受限能力, 需监督)
[非官方(违规)] unofficial: allowed=False - 非官方/私有接入违反Vinted ToS, ERP拒绝支持
=== 2. 多语言分类 (locale驱动) ===
fr: dresses → Robes
de: dresses → Kleider
nl: dresses → Jurken
ヘ 尺码 women S → EU 36
=== 3. GPSR 合规校验 (受限品类) ===
不合规项: ['受限品类(翻新/电子/儿童/化妆品)必须声明欧盟责任实体', '责任实体名称必填', ...]
合规项: []
=== 4. 一对一交易模型 (quantity必须为1) ===
发布: 1 (一对一, quantity=1)
=== 5. quantity≠1 → 拦截 ===
🚫 ['Vinted一对一交易, quantity必须为1 (got 50)']
=== 6. 多语言发布 (同一商品 × 多市场) ===
[fr] {'_mock': True, ..., 'body': {'title': 'Robe Zara 38', ...}}
[de] {'_mock': True, ..., 'body': {'title': 'Zara Kleid 38', ...}}
[nl] {'_mock': True, ..., 'body': {'title': 'Zara Jurk 38', ...}}
=== 7. 库存=状态同步 (非SKU扣减) ===
卖出后: status=sold quantity=0
→ 统一StockChange: qty=0 warehouse=VINTED_FR
=== 8. 接入统一适配层 ===
已注册: ['vinted']
统一sync_stock(卖出→inactive): {'_mock': True, 'method': 'PATCH', 'path': '/items/VIN-001', ...}四、六个对接铁律
路径优先于技术:先确认走 Business API(准入)还是受控接入,并在代码层拒绝非官方路径(
PathResolver)——灰色接入的账号封禁风险远大于对接收益。一对一交易是模型,不是配置:
quantity强制为 1,ERP 不能把 Vinted 当 SKU 库存源。前篇StockEngine的available对 Vinted 只有 0/1 两态,多件库存必须在 ERP 侧拆成多个 listing。locale 是第一公民:分类名、尺码、标题、描述全部按 locale 分支。
LocaleCatalog统一存 EU 标准码 + 按市场翻译,绝不硬编码"M"。GPSR 是欧盟硬合规:受限品类(翻新/电子/儿童/化妆品)必须责任实体 + 安全声明,否则下架。
GpsrDeclaration.validate()放在发布前门禁。尺码按 catalog 分支:同一"M"在 women/men/kids 下对应不同 EU 码,
catalog_sizes必须按 gender/品类查——跨 catalog 硬转 = 尺码错乱 = 退货。多语言内容别机翻糊弄:
title/description存Dict[locale, str],每个市场用本地化文案,机翻痕迹重会被降权/投诉。
五、Vinted 与国内平台对照(纳入统一模型)
维度 | Vinted | 国内(闲鱼/拼多多) |
|---|---|---|
交易单元 | 一对一(item) | SKU × N件 |
库存语义 | 状态同步 | 数量扣减 |
市场模型 | locale 多市场 | 单一中文市场 |
分类 | locale 驱动分类树 | 统一类目 |
尺码 | catalog × EU码 | 自由文本/SKU |
合规 | GPSR(欧盟) | 国内广告法/平台规则 |
API开放度 | 准入制 | 注册即可(多数) |
对接深度分 | 中等(受限于准入) | 4~5(前篇实测) |
Vinted 的VintedItem直接映射前篇统一Product,但库存侧要走VintedStockSync做"状态语义转换"——这是适配层的价值所在。
六、和前16篇的衔接
把VintedAdapter接入九平台统一中台(前篇unified_adapter_layer.py):
MarketplaceAdapterProtocol 兼容,registry.register(adapter)即用,platform="vinted";库存语义转换
VintedStockSync.to_unified():把 Vinted 的 0/1 状态映射为统一StockChange,让前篇StockEngine的多平台同源库存能容纳"非SKU型"平台——这是模型层面的扩展;合规门禁:
GpsrDeclaration+PathResolver是ComplianceGate的欧盟维度扩展(国内管图片/字段,跨境管主体/税务/GPSR);多语言:
LocaleCatalog的title: Dict[locale, str]模式可推广到 Back Market(法语/英语)、Mercari(日语)——统一"按市场存原文 + 按市场翻译";接入深度评估:用前篇
DepthScorer,Vinted 因"准入制+一对一+受限写能力"评分中等,明确标注 capabilities(受控路径下publish/stock/order可能不全),业务层按supports()降级;GPSR 监控接入
ObservabilityMiddleware:受限品类未声明责任实体 = 红色指标。
Vinted 对接的本质,是把"欧洲二手时尚的特殊性"(一对一交易 + 多locale + GPSR)翻译成适配层的显式规则——路径合规比接口通勤更重要,语义翻译比CRUD更值钱。
vinted_adapter.py 扩成完整模块 commerce-mesh/adapters/vinted/:真实 OAuth 2.0 Authorization Code 流(token刷新,复用前篇 TokenManager)、多locale分类树缓存、catalog_sizes 全量映射、GPSR 声明落库 + 发布前校验中间件?vinted_adapter.py