init: Prefect ETL sync project (RIIN/SDS → PG) with 12 sync flows

This commit is contained in:
yeuimu
2026-07-23 08:13:09 +00:00
commit 4cba1da4e1
41 changed files with 6219 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
"""RIIN 分销平台 REST API 客户端。"""
from api.riin.client import RiinDisClient
from api.riin.models import OrderPageRequest
__all__ = ["RiinDisClient", "OrderPageRequest"]
+329
View File
@@ -0,0 +1,329 @@
"""RIIN 分销平台 REST API 客户端(移植自 @inkreach/riin-dis)。
Base URL: https://tshirt.riin.com
认证: username + MD5(password) -> Bearer token(Authorization 头),401 自动重登。
"""
from __future__ import annotations
import hashlib
from typing import Any
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
from api._common import TTLCache, chunk, parse_id_text, run_concurrent
from api._common.http import TokenHttpClient
from api.riin.models import (
Article,
Color,
CustomerListResponse,
CustomerShopListResponse,
ExpressInfo,
FactoryInfoListResponse,
LoginResult,
OrderGoodsResponse,
OrderListCountResponse,
OrderLogResponse,
OrderPageRequest,
OrderPageResponse,
Size,
ThirdInterfaceCustomer,
)
BASE_URL = "https://tshirt.riin.com"
BATCH_SIZE = 500
class _ListResponse(BaseModel):
"""通用列表响应壳(辅助主数据用)。"""
model_config = ConfigDict(extra="allow", populate_by_name=True, alias_generator=to_camel)
successful: bool | None = None
message: str | None = None
error_code: str | None = None
data: list[Any] = []
class _PageResponse(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True, alias_generator=to_camel)
successful: bool | None = None
message: str | None = None
error_code: str | None = None
data: dict[str, Any] = {}
class RiinDisClient(TokenHttpClient):
"""RIIN 分销系统客户端:认证 / 订单 / 店铺 / 辅助主数据。"""
def __init__(
self,
username: str,
password: str,
*,
cache_ttl_seconds: int | float = 300,
concurrency: int = 10,
) -> None:
super().__init__(
BASE_URL,
token_header="Authorization",
fixed_headers={"X-Time-Zone": "UTC+8", "X-Requested-With": "XMLHttpRequest"},
login_markers=("auth/api/auth/login",),
)
self._username = username
self._password = password
self._cache: TTLCache[Any] = TTLCache(cache_ttl_seconds)
self._concurrency = concurrency
# ----- 认证 -----
def _login(self) -> str:
md5_password = hashlib.md5(self._password.encode()).hexdigest()
data = self.post("auth/api/auth/login", body={"username": self._username, "password": md5_password})
result = LoginResult.model_validate(data["data"])
self._token = result.token
return self._token
# ----- 订单 -----
def _build_order_page_body(self, params: OrderPageRequest) -> dict[str, Any]:
has_oid_or_courier = bool(params.platform_oid_list or params.courier_number_list)
body: dict[str, Any] = {
"pageIndex": params.page_index if params.page_index is not None else 1,
"pageSize": params.page_size if params.page_size is not None else 20,
"shopCodeList": params.shop_code_list or [],
"orderStatus": params.order_status if params.order_status is not None else None,
"dayRangeGroup": params.day_range_group if params.day_range_group is not None else None,
"hasRefund": params.has_refund if params.has_refund is not None else False,
}
if not has_oid_or_courier:
body["timeType"] = params.time_type if params.time_type is not None else 3
if params.start_time:
body["startTime"] = params.start_time
if params.end_time:
body["endTime"] = params.end_time
elif params.time_type is not None:
body["timeType"] = params.time_type
if params.platform_oid_list:
body["platformOidList"] = parse_id_text(params.platform_oid_list)
if params.courier_number_list:
body["courierNumberList"] = parse_id_text(params.courier_number_list)
if params.sub_id_list:
body["subIdList"] = params.sub_id_list
if params.goods_title:
body["goodsTitle"] = params.goods_title
if params.mapping_sku_code:
body["mappingSkuCode"] = params.mapping_sku_code
if params.platform_type is not None:
body["platformType"] = params.platform_type
if params.order_source is not None:
body["orderSource"] = params.order_source
if params.factory_ids:
body["factoryIds"] = params.factory_ids
if params.express_source is not None:
body["expressSource"] = params.express_source
if params.delivery_courier:
body["deliveryCourier"] = params.delivery_courier
if params.receiver_country:
body["receiverCountry"] = params.receiver_country
if params.region:
body["region"] = params.region
if params.receiver_province:
body["receiverProvince"] = params.receiver_province
if params.craft_type is not None:
body["craftType"] = params.craft_type
if params.style_code_list:
body["styleCodeList"] = params.style_code_list
if params.color_code_list:
body["colorCodeList"] = params.color_code_list
if params.size_code_list:
body["sizeCodeList"] = params.size_code_list
if params.seller_remark:
body["sellerRemark"] = params.seller_remark
if params.urgent_order is not None:
body["urgentOrder"] = params.urgent_order
if params.third_interface_customer_id:
body["thirdInterfaceCustomerId"] = params.third_interface_customer_id
if params.platform_status_list:
body["platformStatusList"] = params.platform_status_list
if params.is_third_flag is not None:
body["isThirdFlag"] = params.is_third_flag
if params.push_failed is not None:
body["pushFailed"] = params.push_failed
if params.assigned_fail_reason:
body["assignedFailReason"] = params.assigned_fail_reason
if params.push_fail_reason:
body["pushFailReason"] = params.push_fail_reason
if params.factory_remark_param:
body["factoryRemarkParam"] = params.factory_remark_param
if params.distributor_remark_param:
body["distributorRemarkParam"] = params.distributor_remark_param
return body
def _get_single_page(self, params: OrderPageRequest) -> OrderPageResponse:
body = self._build_order_page_body(
params.model_copy(update={"page_index": params.page_index or 1, "page_size": params.page_size or 20})
)
data = self.post("distribution/order/page", body=body)
return OrderPageResponse.model_validate(data)
def _get_all_pages(self, params: OrderPageRequest) -> OrderPageResponse:
page_size = params.page_size or 200
first = self._get_single_page(params.model_copy(update={"page_index": 1, "page_size": page_size}))
total_count = first.data.total
total_pages = -(-total_count // page_size) # ceil
if total_pages <= 1:
return first
remaining_indices = list(range(2, total_pages + 1))
def fetch(i: int) -> OrderPageResponse:
return self._get_single_page(params.model_copy(update={"page_index": i, "page_size": page_size}))
remaining = run_concurrent([lambda i=i: fetch(i) for i in remaining_indices], limit=self._concurrency)
records = list(first.data.records)
for r in remaining:
records.extend(r.data.records)
return OrderPageResponse(
successful=True,
data={"records": records, "total": total_count},
)
def get_orders(self, params: OrderPageRequest | None = None) -> OrderPageResponse:
"""获取订单。支持三种模式:limit(取首页 N 条)/ all(全量分页)/ 单页。"""
params = params or OrderPageRequest()
needs_batching = (
(params.platform_oid_list and len(parse_id_text(params.platform_oid_list)) > BATCH_SIZE)
or (params.courier_number_list and len(parse_id_text(params.courier_number_list)) > BATCH_SIZE)
)
if needs_batching:
return self._get_orders_batched(params)
if params.limit:
return self._get_single_page(params.model_copy(update={"page_index": 1, "page_size": params.limit}))
if params.all:
return self._get_all_pages(params)
return self._get_single_page(params)
def _get_orders_batched(self, params: OrderPageRequest) -> OrderPageResponse:
oid_batches = chunk(parse_id_text(params.platform_oid_list), BATCH_SIZE) if params.platform_oid_list else [None]
courier_batches = (
chunk(parse_id_text(params.courier_number_list), BATCH_SIZE) if params.courier_number_list else [None]
)
def fetch(oid_batch: list[str] | None, courier_batch: list[str] | None) -> OrderPageResponse:
update: dict[str, Any] = {}
if oid_batch is not None:
update["platform_oid_list"] = ",".join(oid_batch)
if courier_batch is not None:
update["courier_number_list"] = ",".join(courier_batch)
batch_params = params.model_copy(update=update)
if params.limit:
return self._get_single_page(
batch_params.model_copy(update={"page_index": 1, "page_size": params.limit})
)
return self._get_all_pages(batch_params)
calls = [
(lambda o=o, c=c: fetch(o, c)) for o in oid_batches for c in courier_batches
]
results = run_concurrent(calls, limit=self._concurrency)
records: list[Any] = []
total = 0
for r in results:
records.extend(r.data.records)
total += r.data.total
if params.limit:
records = records[: params.limit]
return OrderPageResponse(successful=True, data={"records": records, "total": total})
def get_order_goods(self, order_id: str) -> OrderGoodsResponse:
data = self.post("distribution/order/goods", body={"orderId": order_id})
return OrderGoodsResponse.model_validate(data)
def get_order_count(
self, params: OrderPageRequest | None = None
) -> OrderListCountResponse:
params = params or OrderPageRequest()
cache_key = f"orderCount:{params.model_dump_json(exclude_none=True)}"
cached = self._cache.get(cache_key)
if cached is not None:
return cached
body = self._build_order_page_body(params)
body.pop("pageIndex", None)
body.pop("pageSize", None)
data = self.post("distribution/order/list/count", body=body)
result = OrderListCountResponse.model_validate(data)
self._cache.set(cache_key, result)
return result
def get_order_logs(self, order_id: str, platform_oid: str) -> OrderLogResponse:
data = self.post("distribution/order/logs", body={"orderId": order_id, "platformOid": platform_oid})
return OrderLogResponse.model_validate(data)
# ----- 店铺 / 工厂 / 客户 -----
def get_customer_shop_list(self) -> CustomerShopListResponse:
cache_key = "shops:customerShopList"
cached = self._cache.get(cache_key)
if cached is not None:
return cached
data = self.post("distribution/order/customer/shop/list", body={})
result = CustomerShopListResponse.model_validate(data)
self._cache.set(cache_key, result)
return result
def get_factory_list(self) -> FactoryInfoListResponse:
cache_key = "shops:factoryList"
cached = self._cache.get(cache_key)
if cached is not None:
return cached
data = self.get("distribution/tenant/getFactoryInfoList")
result = FactoryInfoListResponse.model_validate(data)
self._cache.set(cache_key, result)
return result
def get_customer_list(self) -> CustomerListResponse:
cache_key = "shops:customerList"
cached = self._cache.get(cache_key)
if cached is not None:
return cached
data = self.post("distribution/customer/listTenantNoDisable", body={})
result = CustomerListResponse.model_validate(data)
self._cache.set(cache_key, result)
return result
# ----- 辅助主数据 -----
def get_articles(self, params: dict[str, Any] | None = None) -> list[Article]:
data = self.post("distribution/articleManagement/list", body=params or {})
resp = _ListResponse.model_validate(data)
return [Article.model_validate(item) for item in resp.data]
def get_sizes(self, params: dict[str, Any] | None = None) -> list[Size]:
data = self.post("distribution/sizeManagement/size/list", body=params or {})
resp = _ListResponse.model_validate(data)
return [Size.model_validate(item) for item in resp.data]
def get_colors(self, params: dict[str, Any] | None = None) -> list[Color]:
data = self.post("distribution/colorManagement/list", body=params or {})
resp = _ListResponse.model_validate(data)
return [Color.model_validate(item) for item in resp.data]
def get_third_party_customers(
self, params: dict[str, Any] | None = None
) -> list[ThirdInterfaceCustomer]:
data = self.post("distribution/thirdInterfaceCustomer/page", body=params or {})
resp = _PageResponse.model_validate(data)
records = resp.data.get("records", []) if isinstance(resp.data, dict) else []
return [ThirdInterfaceCustomer.model_validate(item) for item in records]
def get_express_info(self) -> list[ExpressInfo]:
data = self.post("distribution/order/tenant/delivery/express", body={})
resp = _ListResponse.model_validate(data)
return [ExpressInfo.model_validate(item) for item in resp.data]
+409
View File
@@ -0,0 +1,409 @@
"""RIIN 分销平台 —— 数据模型(pydantic v2)。
忠实还原 @inkreach/riin-dis 的 TypeScript 类型,extra='allow' 兼容未知字段。
"""
from __future__ import annotations
from enum import IntEnum
from typing import Any
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class PlatformType(IntEnum):
TIKTOK = 11
TEMU = 13
SHEIN = 17
WALMART = 19
class ShopType(IntEnum):
TIKTOK = 11
TEMU = 13
SHEIN = 17
WALMART = 19
class OrderStatusValue(IntEnum):
NOT_PUSHED = -4
PENDING_REVIEW = -5
ASSIGN_FAILED = -6
UNDER_REVIEW = -3
PENDING_PUSH = 1
UNDER_REVIEW_DISTRIBUTOR = 3
PUSHED = 4
IN_PRODUCTION = 5
SHIPPED = 12
COMPLETED = 13
CANCELLED = 14
CLOSED = 15
DAY_RANGE_3 = 101
DAY_RANGE_3_7 = 102
DAY_RANGE_7_15 = 103
DAY_RANGE_15_PLUS = 104
class ExpressSource(IntEnum):
FACTORY = 1
PLATFORM_LABEL = 2
SELF_LABEL = 3
class TimeType(IntEnum):
ORDER_CREATE = 1
PUSH_FACTORY = 5
MERCHANT_PUSH = 3
SHIP = 4
class _RiinModel(BaseModel):
model_config = ConfigDict(
extra="allow",
populate_by_name=True,
alias_generator=to_camel,
)
# ----- 认证 -----
class LoginResult(_RiinModel):
id: str | None = None
tenant_id: str | None = None
sub_id: str | None = None
tenant_name: str | None = None
username: str | None = None
nickname: str | None = None
user_type: str | None = None
token: str
token_flag: str | None = None
invite_code: str | None = None
main_account: bool | None = None
factory: bool | None = None
distribution: bool | None = None
sub_distribution: bool | None = None
# ----- 订单 -----
class ExpressItem(_RiinModel):
express_name: str | None = None
ship_logistics_type: str | None = None
class Order(_RiinModel):
id: str
tenant_id: str | None = None
sub_id: str | None = None
sub_name: str | None = None
source_platform_oid: str | None = None
platform_oid: str | None = None
platform_order_name: str | None = None
order_status: int | None = None
order_type: int | None = None
goods_total_qty: int | None = None
platform_order_status: str | None = None
platform_refund_status: str | None = None
platform_shop_code: str | None = None
platform_shop_id: str | None = None
platform_shop_name: str | None = None
platform_type: int | None = None
sub_platform_type: int | None = None
seller_flag: str | None = None
factory_remark: str | None = None
distributor_remark: str | None = None
seller_remark: str | None = None
buyer_remark: str | None = None
delivery_courier: str | None = None
express_code: str | None = None
express_fee: float | None = None
express_source: int | None = None
courier_number: str | None = None
consignee_name: str | None = None
phone: str | None = None
email: str | None = None
address: str | None = None
receiver_country: str | None = None
receiver_province: str | None = None
receiver_city: str | None = None
receiver_district: str | None = None
receiver_town: str | None = None
post_code: str | None = None
currency: str | None = None
currency_type: str | None = None
total_fee: float | None = None
payment: float | None = None
received_payment: float | None = None
total_price: float | None = None
total_sell_price: float | None = None
post_fee: float | None = None
order_create_time: str | None = None
pre_shipping_time: str | None = None
shipping_time: str | None = None
order_pay_time: str | None = None
order_time: str | None = None
finish_time: str | None = None
cancel_time: str | None = None
audit_time: str | None = None
push_time: str | None = None
distributor_push_time: str | None = None
distributor_push_target_name: str | None = None
except_last_shipping_time: str | None = None
push_msg: str | None = None
pushed_failed: str | None = None
assigned_fail_reason: str | None = None
urgent_order: int | None = None
is_third_flag: bool | None = None
is_custom_product: bool | None = None
express_list: list[ExpressItem] = []
class OrderPageData(_RiinModel):
records: list[Order] = []
total: int = 0
class OrderPageResponse(_RiinModel):
successful: bool | None = None
message: str | None = None
error_code: str | None = None
data: OrderPageData = OrderPageData()
class PrintingImage(_RiinModel):
id: str | None = None
type: int | None = None
url: str | None = None
name: str | None = None
code: str | None = None
class OrderGoodsImageObjs(_RiinModel):
sku_image: str | None = None
printing_image: str | None = None
printing_images: list[PrintingImage] = []
sku_images: list[str] | None = None
print_urls: list[str] = []
sku_urls: list[str] = []
class OrderGoods(_RiinModel):
id: str
order_id: str | None = None
platform_oid: str | None = None
platform_spu_id: str | None = None
platform_sku_id: str | None = None
title: str | None = None
specification: str | None = None
goods_status: str | None = None
refund_status: str | None = None
price: float | None = None
sell_price: float | None = None
num: int | None = None
total_price: float | None = None
total_sell_price: float | None = None
sku_image: str | None = None
mapping_sku_code: str | None = None
factory_mapping_sku_code: str | None = None
style_code: str | None = None
image_code: str | None = None
size_code: str | None = None
color_code: str | None = None
style_name: str | None = None
image_name: str | None = None
size_name: str | None = None
color_name: str | None = None
product_code: str | None = None
craft_type: int | None = None
print_position: str | None = None
image_objs: Any | None = None
class OrderGoodsResponse(_RiinModel):
successful: bool | None = None
message: str | None = None
error_code: str | None = None
data: list[OrderGoods] = []
class OrderListCount(_RiinModel):
assigned_suceessed: int = 0
factory_auditing: int = 0
in_producing: int = 0
shipped: int = 0
closed: int = 0
refunding: int = 0
refunded: int = 0
rejecting: int = 0
rejected_to_shop: int = 0
day_range_group1: int = 0
day_range_group2: int = 0
day_range_group3: int = 0
day_range_group4: int = 0
class OrderListCountResponse(_RiinModel):
successful: bool | None = None
message: str | None = None
error_code: str | None = None
data: OrderListCount = OrderListCount()
class OrderLog(_RiinModel):
id: str | None = None
order_id: str | None = None
platform_oid: str | None = None
operate_type: str | None = None
operate_msg: str | None = None
operator_name: str | None = None
operate_time: str | None = None
class OrderLogResponse(_RiinModel):
successful: bool | None = None
message: str | None = None
error_code: str | None = None
data: list[OrderLog] = []
# ----- 店铺 / 工厂 / 客户 -----
class CustomerShop(_RiinModel):
tenant_id: str | None = None
tenant_name: str | None = None
shop_id: str | None = None
shop_code: str | None = None
shop_name: str | None = None
shop_type: int | None = None
sub_id: str | None = None
class CustomerShopListResponse(_RiinModel):
successful: bool | None = None
message: str | None = None
error_code: str | None = None
data: list[CustomerShop] = []
class FactoryInfo(_RiinModel):
factory_id: str | None = None
phone: str | None = None
contact: str | None = None
address: str | None = None
name: str | None = None
logo_url: str | None = None
class FactoryInfoListResponse(_RiinModel):
successful: bool | None = None
message: str | None = None
error_code: str | None = None
data: list[FactoryInfo] = []
class Customer(_RiinModel):
id: str | None = None
tenant_id: str | None = None
customer_tenant_id: str | None = None
customer_name: str | None = None
username: str | None = None
contacts: str | None = None
mobile: str | None = None
settlement_currency: int | None = None
disable: int | None = None
class CustomerListResponse(_RiinModel):
successful: bool | None = None
message: str | None = None
error_code: str | None = None
data: list[Customer] = []
# ----- 辅助主数据 -----
class Article(_RiinModel):
id: str | None = None
code: str | None = None
name: str | None = None
class Size(_RiinModel):
id: str | None = None
code: str | None = None
name: str | None = None
class Color(_RiinModel):
id: str | None = None
code: str | None = None
name: str | None = None
class ThirdInterfaceCustomer(_RiinModel):
id: str | None = None
name: str | None = None
class ExpressInfo(_RiinModel):
id: str | None = None
name: str | None = None
code: str | None = None
class PageData(_RiinModel):
records: list[Any] = []
total: int = 0
# ----- 查询参数(请求 schema)-----
class OrderPageRequest(BaseModel):
"""RIIN 订单分页查询参数。timeType/startTime/endTime 等可选。"""
model_config = ConfigDict(extra="allow")
time_type: int | None = None
start_time: str | None = None
end_time: str | None = None
platform_oid_list: str | None = None
shop_code_list: list[str] | None = None
courier_number_list: str | None = None
sub_id_list: list[str] | None = None
goods_title: str | None = None
mapping_sku_code: str | None = None
platform_type: int | None = None
order_source: int | None = None
factory_ids: list[str] | None = None
express_source: int | None = None
delivery_courier: str | None = None
receiver_country: str | None = None
region: str | None = None
receiver_province: str | None = None
craft_type: int | None = None
style_code_list: list[str] | None = None
color_code_list: list[str] | None = None
size_code_list: list[str] | None = None
seller_remark: str | None = None
urgent_order: int | None = None
third_interface_customer_id: str | None = None
platform_status_list: list[str] | None = None
is_third_flag: bool | None = None
page_index: int | None = None
page_size: int | None = None
order_status: int | None = None
day_range_group: int | None = None
has_refund: bool | None = None
push_failed: bool | None = None
assigned_fail_reason: str | None = None
push_fail_reason: str | None = None
factory_remark_param: str | None = None
distributor_remark_param: str | None = None
limit: int | None = None
all: bool | None = None