×

《多店铺京东订单统一拉取:JOS API + 分布式调度 + 幂等设计实战》(附python源码)

万邦科技Lex 万邦科技Lex 发表于2026-07-15 09:45:58 浏览25 评论0

抢沙发发表评论

多店铺京东订单统一拉取的核心在于:每个店铺独立 AccessToken + 增量时间窗拉取 jingdong.pop.order.search+ 以 (tid, shop_id)为唯一键做数据库幂等 + 分布式锁/Redis NX 防多节点并发重复处理 + 断点续跑防漏单。下面给你一套可直接落地的 Python 实战源码

一、整体架构

┌────────────────────┐
│ 分布式调度器         │  APScheduler / Celery Beat
│ 遍历 shop_auth 表    │
└───────┬────────────┘
        │ 每店铺独立 token
        ▼
┌────────────────────┐
│ JdOrderSyncPerShop │
│ 1. 获取分布式锁     │  Redis SET shop_id:sync  NX EX
│ 2. 增量拉取订单     │  start_modified ~ end_modified
│ 3. 逐单查明细       │  jd.order.detail.get
│ 4. 幂等入库         │  INSERT ON CONFLICT(tid,shop_id) DO NOTHING
│ 5. 更新断点时间     │  last_sync_time
│ 6. 释放锁           │
└────────────────────┘

二、核心源码(Python · 含签名 / 分布式锁 / 幂等 / 断点)

# jd_multi_shop_order_sync.py
"""
多店铺京东订单统一拉取实战
- JOS MD5签名(秒级timestamp)
- Redis分布式锁(防止多节点同时跑同一店铺)
- 增量同步 modified 时间窗
- 幂等入库 (tid, shop_id) UNIQUE
- 断点续跑(记录 last_sync_time)
依赖:
  pip install requests redis apscheduler
"""
import hashlib
import json
import time
import datetime
import requests
import redis
from typing import Dict, List

# ================== 配置 ==================
APP_KEY = "YOUR_JD_APP_KEY"
APP_SECRET = "YOUR_JD_APP_SECRET"
REDIS_HOST = "localhost"
REDIS_PORT = 6379
REDIS_DB = 0
SYNC_INTERVAL_MIN = 30          # 增量窗口分钟数
LOCK_TTL_SEC = 300               # 分布式锁过期时间
# ==========================================

rds = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB)


# ---------- JOS Client ----------
class JdJosClient:
    GW = "https://api.jd.com/routerjson"

    def __init__(self, app_key, app_secret):
, self.ask = app_key, app_secret

    def _sign(self, params: Dict) -> str:
        filt = sorted(
            (k, v) for k, v in params.items()
            if k != "sign" and v is not None and str(v).strip() != ""
        )
        qs = "".join(f"{k}{v}" for k, v in filt)
        return hashlib.md5(f"{self.ask}{qs}{self.ask}".encode()).hexdigest().upper()

    def call(self, method, biz, token):
        p = {
            "app_key": self.ak,
            "method": method,
            "timestamp": str(int(time.time())),   # 秒级!
            "format": "json",
            "v": "2.0",
            "sign_method": "md5",
            "360buy_param_json": json.dumps(biz, ensure_ascii=False,
                                             separators=(',', ':')),
            "access_token": token
        }
        p["sign"] = self._sign(p)
        r = requests.post(self.GW, data=p, timeout=15)
        r.raise_for_status()
        d = r.json()

        resp_key = method.replace(".", "_") + "_response"
        if resp_key not in d:
            for k in d:
                if k.endswith("_response"):
                    resp_key = k
                    break
        data = d.get(resp_key, d)
        if "error_response" in str(data):
            err = d.get(resp_key, {}).get("error_response") or d.get("error_response")
            if err:
                raise Exception(f"JOS[{err.get('code')}]: {err.get('zh_desc')}")
        return data

    def list_orders(self, token, start, end, page=1, page_size=100):
        return self.call("jingdong.pop.order.search", {
            "start_modified": start,
            "end_modified": end,
            "order_state": "WAIT_SELLER_STOCK_OUT,WAIT_BUYER_CONFIRM_GOODS,FINISHED",
            "page": page,
            "page_size": min(page_size, 100)
        }, token)

    def order_detail(self, token, order_id):
        return self.call("jd.order.detail.get", {
            "orderId": str(order_id),
            "fields": "orderId,status,payment,buyer_nick,receiver_name,"
                      "receiver_mobile,orders.skuId,orders.outerSkuId,"
                      "orders.num,orders.price,created,modified"
        }, token)


# ---------- 分布式锁 ----------
def acquire_lock(shop_id: str) -> str:
    """Redis SET NX EX,返回 identifier 或 None"""
    identifier = str(time.time())
    lock_key = f"jd_sync_lock:{shop_id}"
    ok = rds.set(lock_key, identifier, nx=True, ex=LOCK_TTL_SEC)
    return identifier if ok else None


def release_lock(shop_id: str, identifier: str):
    lock_key = f"jd_sync_lock:{shop_id}"
    # Lua等效:仅当值匹配才删,防误释放
    with rds.pipeline() as pipe:
        while True:
            try:
                pipe.watch(lock_key)
                if pipe.get(lock_key) == identifier.encode():
                    pipe.multi()
                    pipe.delete(lock_key)
                    pipe.execute()
                break
            except redis.WatchError:
                continue


# ---------- 断点 & 幂等(SQLite示例,生产换 MySQL/PG) ----------
def init_db(db="jd_erp.db"):
    conn = __import__("sqlite3").connect(db)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS erp_order(
            tid TEXT,
            shop_id TEXT,
            status TEXT,
            payment REAL,
            buyer_nick TEXT,
            created TEXT,
            PRIMARY KEY(tid, shop_id)
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS shop_sync_point(
            shop_id TEXT PRIMARY KEY,
            last_sync_time TEXT
        )
    """)
    conn.commit()
    return conn


def load_last_time(conn, shop_id):
    cur = conn.execute("SELECT last_sync_time FROM shop_sync_point WHERE shop_id=?",
                       (shop_id,))
    row = cur.fetchone()
    return row[0] if row else None


def save_last_time(conn, shop_id, t):
    conn.execute("""
        INSERT INTO shop_sync_point(shop_id,last_sync_time)
        VALUES(?,?) ON CONFLICT(shop_id) DO UPDATE SET last_sync_time=excluded.last_sync_time
    """, (shop_id, t))
    conn.commit()


def upsert_order(conn, shop_id, order):
    conn.execute("""
        INSERT INTO erp_order(tid,shop_id,status,payment,buyer_nick,created)
        VALUES(?,?,?,?,?,?)
        ON CONFLICT(tid,shop_id) DO NOTHING
    """, (
        str(order.get("orderId")),
        shop_id,
        order.get("status"),
        float(order.get("payment") or 0),
        order.get("buyer_nick", ""),
        order.get("created", "")
    ))
    conn.commit()


# ---------- 单店铺同步 ----------
def sync_one_shop(client: JdJosClient, conn, shop: Dict):
    shop_id = shop["shop_id"]
    token = shop["access_token"]

    lock_id = acquire_lock(shop_id)
    if not lock_id:
        print(f"⚠️  店铺[{shop_id}] 正在其他节点同步,跳过")
        return

    try:
        now = datetime.datetime.now()
        last = load_last_time(conn, shop_id)
        if last:
            start = last
        else:
            start = (now - datetime.timedelta(minutes=SYNC_INTERVAL_MIN * 2)
                    ).strftime("%Y-%m-%d %H:%M:%S")
        end = now.strftime("%Y-%m-%d %H:%M:%S")

        print(f"▶ 店铺[{shop_id}] 增量 {start} ~ {end}")

        page = 1
        while True:
            rsp = client.list_orders(token, start, end, page=page)
            order_list = (rsp.get("popOrderSearch", {})
                          .get("orderSearch", {})
                          .get("orderInfoList", [])) or []
            total = rsp.get("popOrderSearch", {}).get("orderSearch", {}
                                                      ).get("orderTotal", 0)

            for o in order_list:
                tid = str(o.get("orderId"))
                # 明细(按需,列表已有基础字段可先upsert)
                detail = client.order_detail(token, tid)
                order_data = detail.get("orderInfo", o)
                upsert_order(conn, shop_id, order_data)

            if len(order_list) < 100 or page * 100 >= total:
                break
            page += 1
            time.sleep(0.15)   # QPS保护

        # 成功更新断点(用当前end,下次从此继续)
        save_last_time(conn, shop_id, end)
        print(f"✅ 店铺[{shop_id}] 同步完成,断点更新至 {end}")

    except Exception as e:
        print(f"❌ 店铺[{shop_id}] 同步异常: {e}")
    finally:
        release_lock(shop_id, lock_id)


# ---------- 多店铺入口 ----------
def sync_all_shops(shop_list: List[Dict]):
    client = JdJosClient(APP_KEY, APP_SECRET)
    conn = init_db()
    for shop in shop_list:
        try:
            sync_one_shop(client, conn, shop)
        except Exception as e:
            print(f"❌ 顶层异常 shop={shop.get('shop_id')}: {e}")


# ===================== 示例 shop_list =====================
if __name__ == "__main__":
    SHOP_LIST = [
        {"shop_id": "SHOP001", "access_token": "SELLER_TOKEN_A"},
        {"shop_id": "SHOP002", "access_token": "SELLER_TOKEN_B"},
    ]
    sync_all_shops(SHOP_LIST)

    # ---- 常驻调度示例(取消注释)----
    # from apscheduler.schedulers.blocking import BlockingScheduler
    # sched = BlockingScheduler()
    # sched.add_job(lambda: sync_all_shops(SHOP_LIST),
    #               'interval', minutes=SYNC_INTERVAL_MIN, id='jd_multi_shop_sync')
    # sched.start()

三、关键设计点说明

  1. 多店铺隔离
    每个店铺一条 shop_auth记录,存 shop_id / access_token / refresh_token,同步时按 shop_id独立跑、独立锁、独立断点时间。

  2. 分布式锁(Redis SET NX EX)
    防止多节点 / 多进程同时跑同一店铺导致重复调用API、重复入库。

  3. 幂等设计
    本地订单表 PRIMARY KEY (tid, shop_id),处理前数据库层 ON CONFLICT DO NOTHING,即使重试/重复推送也不重复建单。

  4. 增量 + 断点续跑
    记录 last_sync_time,每次用 start_modified=last_sync_time, end_modified=now拉取,服务重启从中继续,避免漏单与超日额度。

  5. QPS保护
    分页间 time.sleep(0.15)+ 令牌桶(前文京东限流篇已给),遇 code=16 / ISP_FLOW_CONTROL指数退避重试。

  6. 对账兜底
    每日凌晨全量比对一次最近 N 天订单,修正极端情况下漏掉的状态变更(回调丢失/网络抖动)。


四、避坑清单

现象
解决
用毫秒时间戳
Invalid Timestamp
JOS用秒级 int(time.time())
订单403
传买家token / 个人应用
企业认证 + 卖家OAuth AccessToken
多节点重复入库
无幂等/无锁
(tid,shop_id)唯一索引 + Redis锁
重启后重复拉全量
无断点记录
持久化 last_sync_time
超日额度
limited-by-app-access-count
增量同步 + 断点,避免全量翻页

一句话总结:多店铺京东订单同步 = 每店铺独立 Seller Token → 增量 modified 时间窗 → Redis NX 锁防并发 → (tid,shop_id)幂等入库 → 记录 last_sync_time 断点续跑 → 日对账兜底,签名/QPS 同单店铺,仅调度与隔离维度不同。


群贤毕至

访客