首次提交:初始化后端、数据库结构与文档代码

This commit is contained in:
Peter
2026-03-23 16:10:29 +08:00
commit 86f384c2d3
95 changed files with 10090 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
===== 1. Customer id=1164 =====
id = 1164
mobile = $wtNj+QiRfyPlG7akDeJ9IA==$1$
yz_open_id = m0DoSB6S627938736830705664
wx_union_id = null
wx_open_id = null
nickname = null
name = null
gender = 0
birthday = null
register_time = null
register_channel = retail_minapp_shelf
member_level_id = null
member_level_name = null
first_pay_time = null
last_pay_time = null
total_pay_amount = 21.80
total_pay_count = 1
customer_tags = null
etl_update_time = 2026-03-19 14:34:07
yz_open_id = m0DoSB6S627938736830705664
===== 2. Orders for this yz_open_id =====
[0] tid=E20260318120236063500049, oid=3133270467018227782, pay_time=null, created_time=2026-03-18 12:02:36, payment=21.80, status=TRADE_CLOSED
===== 3. Aggregation result for this yz_open_id =====
yz_open_id = m0DoSB6S627938736830705664
buyer_phone = $wtNj+QiRfyPlG7akDeJ9IA==$1$
first_pay_time = null
last_pay_time = null
total_pay_amount = 21.80
total_pay_count = 1
source_platforms = retail_minapp_shelf
===== 4. Sample: 5 customers with NULL first_pay_time =====
id=15, yz_open_id=gopJBUIh612307439861112832, first=null, last=null, amount=0.00, count=0
id=29, yz_open_id=RBmUTzIw619910823342542848, first=null, last=null, amount=0.00, count=0
id=301, yz_open_id=dk30bXhc621423963670724608, first=null, last=null, amount=0.00, count=0
id=403, yz_open_id=HKZwDOR2619915764425564160, first=null, last=null, amount=0.00, count=0
id=454, yz_open_id=jZpq2Gvq613696894723452928, first=null, last=null, amount=0.00, count=0
===== 5. Order table pay_time stats =====
total=6793, pay_time_null=16, pay_time_not_null=6777
===== 6. Sample: orders where pay_time IS NULL =====
[0] tid=E20260318124237016806181, status=TRADE_CLOSED, created=2026-03-18 12:42:37
[1] tid=E20260318124237016806181, status=TRADE_CLOSED, created=2026-03-18 12:42:37
[2] tid=E20260318124237016806181, status=TRADE_CLOSED, created=2026-03-18 12:42:37
[3] tid=E20260318122210081100023, status=TRADE_CLOSED, created=2026-03-18 12:22:11
[4] tid=E20260318121139086206207, status=TRADE_CLOSED, created=2026-03-18 12:11:39

View File

@@ -0,0 +1,81 @@
===== Step 1: Get Token =====
access_token: b15de01b66759b91e045deb169c0b69
===== Step 2: Get a yz_open_id from trade API =====
Found yz_open_id from order: anyuO2rv621793313242066944
buyer_info: {"outer_user_id":"","buyer_phone":"$LBOS9XKFoggqVOFvWABBEw==$1$","yz_open_id":"anyuO2rv621793313242066944","fans_type":0,"fans_id":0,"fans_nickname":""}
yz_open_id from order: anyuO2rv621793313242066944
===== Step 3A: POST youzan.scrm.customer.detail.get/1.0.1 =====
URL: https://open.youzanyun.com/api/youzan.scrm.customer.detail.get/1.0.1?access_token=b15de01b66759b91e045deb169c0b69
Request Body: {"yz_open_id":"anyuO2rv621793313242066944","fields":"user_base,tags,benefit_level,behavior,credit"}
Status: 200 OK
Response:
{
"trace_id" : "yz7-0a34f87c-1773885978273-913273",
"code" : 141001107,
"data" : {
"cards" : [ ],
"level_infos" : [ ],
"rights" : [ ],
"customer_attrInfos" : [ ],
"tags" : [ ]
},
"success" : false,
"message" : "客户不存在"
}
--- data 内包含的 key: cards, level_infos, rights, customer_attrInfos, tags
--- user_base: NOT FOUND
--- behavior: NOT FOUND
--- tags: []
===== Step 3B: POST youzan.scrm.customer.get/3.0.0 (old API) =====
URL: https://open.youzanyun.com/api/youzan.scrm.customer.get/3.0.0?access_token=b15de01b66759b91e045deb169c0b69
Request Body: {"yz_open_id":"anyuO2rv621793313242066944"}
Status: 200 OK
Response:
{
"trace_id" : "yz7-0a34f87c-1773885978362-696693",
"code" : 141500101,
"success" : false,
"message" : "缺少必要参数, mobile/fans_id/open_user_id 三选一传入"
}
===== Step 3C: GET youzan.scrm.customer.detail.get/1.0.1 (should fail per doc) =====
URL: https://open.youzanyun.com/api/youzan.scrm.customer.detail.get/1.0.1?access_token=b15de01b66759b91e045deb169c0b69&yz_open_id=anyuO2rv621793313242066944&fields=user_base,tags,behavior
Status: 200 OK
Response:
{
"trace_id" : "yz7-0a34f87c-1773885978420-231815",
"code" : 141001107,
"data" : {
"cards" : [ ],
"level_infos" : [ ],
"rights" : [ ],
"customer_attrInfos" : [ ],
"tags" : [ ]
},
"success" : false,
"message" : "客户不存在"
}
===== Step 4: POST scrm.customer.detail.get with account_info =====
URL: https://open.youzanyun.com/api/youzan.scrm.customer.detail.get/1.0.1?access_token=b15de01b66759b91e045deb169c0b69
Request Body: {"account_info":{"account_id":"13800138000","account_type":2},"fields":"user_base,tags,behavior"}
Status: 200 OK
Response:
{
"trace_id" : "yz7-0a34f87c-1773885978484-47619",
"code" : 141001107,
"data" : {
"cards" : [ ],
"level_infos" : [ ],
"rights" : [ ],
"customer_attrInfos" : [ ],
"tags" : [ ]
},
"success" : false,
"message" : "客户不存在"
}

View File

@@ -0,0 +1,2 @@
warehouse=CY101008, skus=[80700330, 80700331, 80700332, 80700335, 80700339, 80700340, 80700341, 80700348, BM251220803262446, BM251220803262446_1, BM251220803262446_10, BM251220803262446_11, BM251220803262446_12, BM251220803262446_13, BM251220803262446_14, BM251220803262446_15, BM251220803262446_16, BM251220803262446_17, BM251220803262446_2, BM251220803262446_3]
Exception: java.lang.RuntimeException - Youzan gateway error: 5001 - 系统异常

View File

@@ -0,0 +1,3 @@
URL: https://open.youzanyun.com/api/youzan.retail.open.query.warehousestock/1.0.0?access_token=***
Body: {"warehouse_code":"CY101008","sku_codes":"[\"BM260227389337223_5\",\"BM260227389337223_6\",\"BM260227452787014\",\"BM260313217255847\",\"BM260318247274160\"]"}
Response: {"gw_err_resp":{"trace_id":"yz7-0a0a6da7-1773913106843-41336","err_msg":"系统异常","err_code":5001}}

View File

@@ -0,0 +1,294 @@
access_token: 12029b5ab6...
======================================================================
Part 1: 有赞API返回的订单中每个商品明细的 item_id + title
======================================================================
--- 订单 #1 tid=E20260320154655006100049 ---
item_id=5560795462 sku_id=0 title=【新客福利】红烧冬瓜 outer_item_id=80700344 outer_sku_id=80700344 num=1
item_id=4601902241 sku_id=0 title=冬菇滑鸡|营养辅菜|稻花香米饭 outer_item_id=P260308068589579 outer_sku_id=P260308068589579 num=1
item_id=4568527117 sku_id=0 title=【不是例汤哦】胡萝卜玉米炖鸡汤(小份) outer_item_id=80401076 outer_sku_id=80401076 num=1
item_id=1 sku_id=0 title=需要餐具(多点不送) outer_item_id= outer_sku_id= num=1
--- 订单 #2 tid=E20260320154535066906213 ---
item_id=1 sku_id=0 title=【福利】太太笑酱油(酿造酱油)1包仅按需出货 outer_item_id= outer_sku_id= num=1
item_id=4539507643 sku_id=0 title=日式照烧肥牛饭 outer_item_id=80700323 outer_sku_id=80700323 num=1
item_id=1 sku_id=0 title=有任何问题请致电 outer_item_id= outer_sku_id= num=1
--- 订单 #3 tid=E20260320154449088900029 ---
item_id=4568529612 sku_id=0 title=【不是例汤哦】胡萝卜玉米炖鸡汤(小份) outer_item_id=80401076 outer_sku_id=80401076 num=1
item_id=5560789995 sku_id=0 title=【新客福利】红烧冬瓜 outer_item_id=80700344 outer_sku_id=80700344 num=1
item_id=5502282297 sku_id=0 title=【超值】台式卤肉|营养辅菜|稻花香米饭 outer_item_id=80700335 outer_sku_id=80700335 num=1
item_id=1 sku_id=0 title=需要餐具(多点不送) outer_item_id= outer_sku_id= num=1
--- 订单 #4 tid=E20260320154418013006169 ---
item_id=1 sku_id=0 title=需要餐具(多点不送) outer_item_id= outer_sku_id= num=1
item_id=1 sku_id=0 title=(镇店)小炒黄牛肉|营养辅菜|稻花香米饭 outer_item_id= outer_sku_id= num=1
item_id=4572844198 sku_id=0 title=稻花香米饭(大份) outer_item_id=80700328 outer_sku_id=80700328 num=1
--- 订单 #5 tid=E20260320154254024900061 ---
item_id=4539506423 sku_id=0 title=花胶猪蹄汤 outer_item_id=80401071 outer_sku_id=80401071 num=1
======================================================================
Part 2: 第1条订单的完整原始JSON (供比对字段结构)
======================================================================
{
"full_order_info" : {
"child_info" : {
"child_orders" : [ ]
},
"remark_info" : {
"buyer_message" : "顾客需要餐具"
},
"address_info" : {
"self_fetch_info" : "",
"delivery_address" : "$moUFuDhzcyGTCtWqCIwNqA==$1$",
"delivery_start_time" : "2026-03-20 16:20:54",
"delivery_end_time" : "2026-03-20 16:20:54",
"delivery_postal_code" : "",
"receiver_name" : "$5cToHjLIXxTDIS8O5ROD5Q==$1$",
"delivery_province" : "广东省",
"delivery_city" : "深圳市",
"delivery_district" : "罗湖区",
"address_extra" : "{\"lon\":114.11738591622816,\"lat\":22.545785599954616}",
"receiver_tel" : "$TgLHMrPYR/ASBKG9LF0lMI1snL5NhAScCoyP6PPFmRs=$1$"
},
"pay_info" : {
"outer_transactions" : [ ],
"post_fee" : "0.00",
"phase_payments" : [ ],
"total_fee" : "61.20",
"payment" : "19.30",
"transaction" : [ ]
},
"buyer_info" : {
"outer_user_id" : "",
"yz_open_id" : "BR41aoU6622821246207410176",
"fans_type" : 0,
"fans_id" : 0,
"fans_nickname" : ""
},
"orders" : [ {
"is_cross_border" : "",
"outer_item_id" : "80700344",
"item_type" : 0,
"discount_price" : "3.90",
"num" : 1,
"unified_sku_id" : "",
"oid" : "3133670457021563334",
"title" : "【新客福利】红烧冬瓜",
"fenxiao_payment" : "0.00",
"item_no" : "",
"buyer_messages" : "",
"root_sku_id" : "",
"is_present" : false,
"cross_border_trade_mode" : "",
"unified_item_id" : "",
"price" : "11.90",
"sub_order_no" : "",
"total_fee" : "3.90",
"fenxiao_price" : "0.00",
"alias" : "3ntp45sodqtj2ce",
"payment" : "2.05",
"item_barcode" : "80700344",
"is_pre_sale" : "",
"outer_sku_id" : "80700344",
"sku_unique_code" : "",
"goods_url" : "https://h5.youzan.com/v2/showcase/goods?alias=3ntp45sodqtj2ce",
"customs_code" : "",
"item_tags" : { },
"item_id" : 5560795462,
"weight" : "",
"sku_id" : 0,
"sku_properties_name" : "",
"pic_path" : "https://img.yzcdn.cn/upload_files/2026/03/01/FgabM7NS_BnTMnnVL8Fr7i7Bf-A2.jpg",
"shop_org_id" : "",
"is_combo" : false,
"pre_sale_type" : "",
"points_price" : "",
"sku_no" : "",
"root_item_id" : "5560792751",
"origin_place_code" : "",
"sku_barcode" : "80700344"
}, {
"is_cross_border" : "",
"outer_item_id" : "P260308068589579",
"item_type" : 0,
"discount_price" : "20.80",
"num" : 1,
"unified_sku_id" : "",
"oid" : "3133670457021563335",
"title" : "冬菇滑鸡|营养辅菜|稻花香米饭",
"fenxiao_payment" : "0.00",
"item_no" : "",
"buyer_messages" : "",
"root_sku_id" : "",
"is_present" : false,
"cross_border_trade_mode" : "",
"unified_item_id" : "",
"price" : "33.90",
"sub_order_no" : "",
"total_fee" : "20.80",
"fenxiao_price" : "0.00",
"alias" : "363nnvnnig3r22d",
"payment" : "12.50",
"item_barcode" : "P260308068589579",
"is_pre_sale" : "",
"outer_sku_id" : "P260308068589579",
"sku_unique_code" : "",
"goods_url" : "https://h5.youzan.com/v2/showcase/goods?alias=363nnvnnig3r22d",
"customs_code" : "",
"item_tags" : { },
"item_id" : 4601902241,
"weight" : "",
"sku_id" : 0,
"sku_properties_name" : "",
"pic_path" : "https://img.yzcdn.cn/upload_files/2026/03/08/FrYmlplGvmVDDltBdq-9s5oX5QM5.png",
"shop_org_id" : "",
"is_combo" : true,
"pre_sale_type" : "",
"points_price" : "",
"sku_no" : "",
"root_item_id" : "5578679049",
"origin_place_code" : "",
"sku_barcode" : "P260308068589579"
}, {
"is_cross_border" : "",
"outer_item_id" : "80401076",
"item_type" : 0,
"discount_price" : "3.90",
"num" : 1,
"unified_sku_id" : "",
"oid" : "3133670457021563336",
"title" : "【不是例汤哦】胡萝卜玉米炖鸡汤(小份)",
"fenxiao_payment" : "0.00",
"item_no" : "",
"buyer_messages" : "",
"root_sku_id" : "",
"is_present" : false,
"cross_border_trade_mode" : "",
"unified_item_id" : "",
"price" : "11.90",
"sub_order_no" : "",
"total_fee" : "3.90",
"fenxiao_price" : "0.00",
"alias" : "1yeoky0v5p4ouwy",
"payment" : "2.05",
"item_barcode" : "80401076",
"is_pre_sale" : "",
"outer_sku_id" : "80401076",
"sku_unique_code" : "",
"goods_url" : "https://h5.youzan.com/v2/showcase/goods?alias=1yeoky0v5p4ouwy",
"customs_code" : "",
"item_tags" : { },
"item_id" : 4568527117,
"weight" : "",
"sku_id" : 0,
"sku_properties_name" : "",
"pic_path" : "https://img.yzcdn.cn/upload_files/2026/01/30/FkpH5FWPkqlzQVs2gk50GViivfql.jpg",
"shop_org_id" : "",
"is_combo" : false,
"pre_sale_type" : "",
"points_price" : "",
"sku_no" : "",
"root_item_id" : "5502191190",
"origin_place_code" : "",
"sku_barcode" : "80401076"
}, {
"is_cross_border" : "",
"outer_item_id" : "",
"item_type" : 0,
"discount_price" : "0.00",
"num" : 1,
"unified_sku_id" : "",
"oid" : "3133670457021563337",
"title" : "需要餐具(多点不送)",
"fenxiao_payment" : "0.00",
"item_no" : "",
"buyer_messages" : "",
"root_sku_id" : "",
"is_present" : false,
"cross_border_trade_mode" : "",
"unified_item_id" : "",
"price" : "0.00",
"sub_order_no" : "",
"total_fee" : "0.00",
"fenxiao_price" : "0.00",
"alias" : "mockItem",
"payment" : "0.00",
"item_barcode" : "",
"is_pre_sale" : "",
"outer_sku_id" : "",
"sku_unique_code" : "",
"goods_url" : "https://h5.youzan.com/v2/showcase/goods?alias=mockItem",
"customs_code" : "",
"item_tags" : { },
"item_id" : 1,
"weight" : "0",
"sku_id" : 0,
"sku_properties_name" : "",
"pic_path" : "http://p0.meituan.net/wmproduct/a54ec40aceb9940eb4742cd6e7280e0b20373.png",
"shop_org_id" : "",
"is_combo" : false,
"pre_sale_type" : "",
"points_price" : "",
"sku_no" : "",
"root_item_id" : "1",
"origin_place_code" : "",
"sku_barcode" : ""
} ],
"out_order_info" : {
"income" : "12.68",
"out_order_promotions" : [ ],
"out_order_agent_service_fee" : "5.12",
"out_order_container_fee" : "2.70",
"out_order_activity_fee_agent_part" : "0.00",
"out_order_no" : "3302044302155123365",
"out_order_activity_fee_shop_part" : "0.00"
},
"source_info" : {
"is_offline_order" : false,
"book_key" : "",
"biz_source" : "trade-oom",
"source" : {
"platform" : "other",
"wx_entrance" : "direct_buy"
},
"order_mark" : "meituan"
},
"order_info" : {
"consign_time" : "",
"order_extra" : {
"is_from_cart" : "false",
"is_member" : "false",
"serial_no" : "W00236"
},
"created" : "2026-03-20 15:46:52",
"offline_id" : 191997006,
"expired_time" : "",
"status_str" : "待发货",
"success_time" : "",
"type" : 0,
"shop_name" : "鸿昌广场店",
"tid" : "E20260320154655006100049",
"confirm_time" : "2026-03-20 15:47:03",
"pay_time" : "2026-03-20 15:46:55",
"node_kdt_id" : 191997006,
"update_time" : "2026-03-20 15:48:59",
"pay_type_str" : "OUTSIDE_PAYMENT",
"is_retail_order" : true,
"pay_type" : 203,
"team_type" : 7,
"refund_state" : 0,
"root_kdt_id" : 154234003,
"close_type" : 0,
"status" : "WAIT_SELLER_SEND_GOODS",
"express_type" : 2,
"order_tags" : {
"is_secured_transactions" : true,
"is_payed" : true
}
}
}
}

View File

@@ -0,0 +1 @@
{"trace_id":"yz7-0a34f813-1773972169728-175647","code":200,"data":{"paginator":{"total_count":17,"page":1,"page_size":5},"offline_spus":[{"category_name":"加菜专区","item_id":5560789477,"plu_code":"","sell_stock_count":"9","is_non_spec":true,"created_at":"2026-02-27 14:07:46","biz_mark_code":"000000000000","title":"红烧冬瓜","specifications":"","measurement":0,"has_multi_sku":false,"sold_num":"0","max_guide_price":"0","sku_models":[{"specs":"[]","max_guide_price":"0","min_guide_price":"0","sku_no":"","price":"6.9","sku_id":26089289330}],"unit":"盒","biz_mark_name":"基础商品","min_guide_price":"0","spu_no":"80700344","price":"6.9","is_display":1},{"category_name":"中式经典","item_id":4593336820,"plu_code":"","sell_stock_count":"1","is_non_spec":true,"created_at":"2026-02-27 14:06:16","biz_mark_code":"000000000000","title":"肉末茄子饭","specifications":"","measurement":0,"has_multi_sku":false,"sold_num":"0","max_guide_price":"0","sku_models":[{"specs":"[]","max_guide_price":"0","min_guide_price":"0","sku_no":"","price":"15.9","sku_id":26089291716}],"unit":"盒","biz_mark_name":"基础商品","min_guide_price":"0","spu_no":"80700342","price":"15.9","is_display":1},{"category_name":"中式经典","item_id":4593328959,"plu_code":"","sell_stock_count":"2","is_non_spec":true,"created_at":"2026-02-27 14:04:46","biz_mark_code":"000000000000","title":"经典黄焖鸡饭🌶","specifications":"","measurement":0,"has_multi_sku":false,"sold_num":"0","max_guide_price":"0","sku_models":[{"specs":"[]","max_guide_price":"0","min_guide_price":"0","sku_no":"","price":"17.9","sku_id":26089283196}],"unit":"盒","biz_mark_name":"基础商品","min_guide_price":"0","spu_no":"80700340","price":"17.9","is_display":1},{"category_name":"主食加量","item_id":4572846598,"plu_code":"","sell_stock_count":"9","is_non_spec":true,"created_at":"2026-02-03 15:36:55","biz_mark_code":"000000000000","title":"米饭(大份)","specifications":"","measurement":0,"has_multi_sku":false,"sold_num":"0","max_guide_price":"0","sku_models":[{"specs":"[]","max_guide_price":"0","min_guide_price":"0","sku_no":"","price":"3.5","sku_id":14964204314}],"unit":"盒","biz_mark_name":"基础商品","min_guide_price":"0","spu_no":"80700328","price":"3.5","is_display":1},{"category_name":"广式炖汤","item_id":4568531703,"plu_code":"","sell_stock_count":"11","is_non_spec":true,"created_at":"2026-01-30 09:47:32","biz_mark_code":"000000000000","title":"胡萝卜玉米炖鸡汤","specifications":"","measurement":0,"has_multi_sku":false,"sold_num":"0","max_guide_price":"0","sku_models":[{"specs":"[]","max_guide_price":"0","min_guide_price":"0","sku_no":"","price":"9.8","sku_id":26075982572}],"unit":"盒","biz_mark_name":"基础商品","min_guide_price":"0","spu_no":"80401076","price":"9.8","is_display":1}]},"success":true,"message":"successful"}

View File

@@ -0,0 +1,51 @@
access_token: b15de01b66759b91e045deb169c0b69
查询日期范围: 2026-03-18 00:00:00 ~ 2026-03-19 00:00:00
============================================================
Test 1: start_update / end_update (当前代码使用的方式)
============================================================
URL: https://open.youzanyun.com/api/youzan.trades.sold.get/4.0.4?access_token=b15de01b66759b91e045deb169c0b69&page_no=1&page_size=100&end_update=2026-03-19 00:00:00&start_update=2026-03-18 00:00:00
success=true, code=200
total_results: 2537
has_next: false
orders on this page: 100
[0] tid=E20260318173417001100015, status=TRADE_SUCCESS, created=2026-03-18 17:34:03, details=3
[1] tid=E20260318174224030106205, status=TRADE_SUCCESS, created=2026-03-18 17:42:20, details=3
============================================================
Test 2: start_created / end_created (按创建时间)
============================================================
URL: https://open.youzanyun.com/api/youzan.trades.sold.get/4.0.4?access_token=b15de01b66759b91e045deb169c0b69&page_no=1&page_size=100&start_created=2026-03-18 00:00:00&end_created=2026-03-19 00:00:00
success=true, code=200
total_results: 2521
has_next: false
orders on this page: 100
[0] tid=E20260318214031081206147, status=WAIT_SELLER_SEND_GOODS, created=2026-03-18 21:40:30, details=2
[1] tid=E20260318205914037200049, status=TRADE_SUCCESS, created=2026-03-18 20:58:23, details=1
============================================================
Test 3: 无时间过滤 (page_size=5, 只看 total_results)
============================================================
URL: https://open.youzanyun.com/api/youzan.trades.sold.get/4.0.4?access_token=b15de01b66759b91e045deb169c0b69&page_no=1&page_size=5
success=true, code=200
total_results: 46494
has_next: false
orders on this page: 5
[0] tid=E20260319103151067400059, status=WAIT_SELLER_SEND_GOODS, created=2026-03-19 10:31:48, details=2
[1] tid=E20260319103133025800029, status=WAIT_SELLER_SEND_GOODS, created=2026-03-19 10:31:29, details=3
============================================================
Test 4: start_created 拉取全量 (page_size=100, 遍历所有页)
============================================================
Page 1: 100 orders, 257 details, total_results=2521, has_next=false
==> 总计: 100 笔订单, 257 条明细 (遍历了 1 页)
============================================================
Test 5: start_update 拉取全量对比
============================================================
Page 1: 100 orders, 276 details, has_next=false
==> 总计: 100 笔订单, 276 条明细 (遍历了 1 页)

83
server/pom.xml Normal file
View File

@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.chuyishidai</groupId>
<artifactId>datahub</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>datahub</name>
<description>餐饮零售数据中台 - 有赞数据同步与洞察服务</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Jackson (JSON) -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1 @@
{"gw_err_resp":{"trace_id":"yz7-0a0a698d-1773911030447-434428","err_msg":"非法的请求凭证","err_code":4201}}

View File

@@ -0,0 +1,14 @@
{
"node_kdt_id" : 190290614,
"reason" : 17,
"kdt_id" : 154234003,
"return_goods" : false,
"created" : "2026-03-22 19:56:29",
"refund_fee" : "8.22",
"modified" : "2026-03-22 19:56:49",
"cs_status" : 1,
"refund_id" : "202603221956283952210605",
"tid" : "E20260322125631060506155",
"delivery_status" : 2,
"status" : "SUCCESS"
}

View File

@@ -0,0 +1,122 @@
package com.chuyishidai.datahub;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.sql.*;
/**
* Quick DB diagnostic for customer id=1164
* Output: server/customer_diag_output.txt
*/
public class CustomerDiagTest {
public static void main(String[] args) throws Exception {
String url = "jdbc:mysql://192.168.70.58:3306/youzandatahub?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
StringBuilder sb = new StringBuilder();
try (Connection conn = DriverManager.getConnection(url, "root", "123456")) {
sb.append("===== 1. Customer id=1164 =====\n");
try (PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM dim_customer_info WHERE id=1164")) {
ResultSet rs = ps.executeQuery();
ResultSetMetaData meta = rs.getMetaData();
if (rs.next()) {
for (int i = 1; i <= meta.getColumnCount(); i++) {
sb.append(String.format(" %-25s = %s%n", meta.getColumnName(i), rs.getString(i)));
}
}
}
// 获取 yz_open_id
String yzOpenId = null;
try (PreparedStatement ps = conn.prepareStatement(
"SELECT yz_open_id FROM dim_customer_info WHERE id=1164")) {
ResultSet rs = ps.executeQuery();
if (rs.next()) yzOpenId = rs.getString(1);
}
sb.append("\nyz_open_id = ").append(yzOpenId).append("\n");
if (yzOpenId != null) {
sb.append("\n===== 2. Orders for this yz_open_id =====\n");
try (PreparedStatement ps = conn.prepareStatement(
"SELECT tid, oid, pay_time, created_time, payment, status FROM dwd_trade_order_detail WHERE yz_open_id=? LIMIT 5")) {
ps.setString(1, yzOpenId);
ResultSet rs = ps.executeQuery();
int row = 0;
while (rs.next()) {
sb.append(String.format(" [%d] tid=%s, oid=%s, pay_time=%s, created_time=%s, payment=%s, status=%s%n",
row++, rs.getString("tid"), rs.getString("oid"),
rs.getString("pay_time"), rs.getString("created_time"),
rs.getString("payment"), rs.getString("status")));
}
if (row == 0) sb.append(" No orders found!\n");
}
sb.append("\n===== 3. Aggregation result for this yz_open_id =====\n");
try (PreparedStatement ps = conn.prepareStatement("""
SELECT
yz_open_id,
MAX(buyer_phone) AS buyer_phone,
MIN(pay_time) AS first_pay_time,
MAX(pay_time) AS last_pay_time,
COALESCE(SUM(payment), 0) AS total_pay_amount,
COUNT(DISTINCT tid) AS total_pay_count,
GROUP_CONCAT(DISTINCT source_platform SEPARATOR ',') AS source_platforms
FROM dwd_trade_order_detail
WHERE yz_open_id = ?
GROUP BY yz_open_id
""")) {
ps.setString(1, yzOpenId);
ResultSet rs = ps.executeQuery();
ResultSetMetaData meta = rs.getMetaData();
if (rs.next()) {
for (int i = 1; i <= meta.getColumnCount(); i++) {
sb.append(String.format(" %-25s = %s%n", meta.getColumnName(i), rs.getString(i)));
}
}
}
}
sb.append("\n===== 4. Sample: 5 customers with NULL first_pay_time =====\n");
try (PreparedStatement ps = conn.prepareStatement(
"SELECT id, yz_open_id, first_pay_time, last_pay_time, total_pay_amount, total_pay_count FROM dim_customer_info WHERE first_pay_time IS NULL LIMIT 5")) {
ResultSet rs = ps.executeQuery();
while (rs.next()) {
sb.append(String.format(" id=%d, yz_open_id=%s, first=%s, last=%s, amount=%s, count=%s%n",
rs.getLong("id"), rs.getString("yz_open_id"),
rs.getString("first_pay_time"), rs.getString("last_pay_time"),
rs.getString("total_pay_amount"), rs.getString("total_pay_count")));
}
}
sb.append("\n===== 5. Order table pay_time stats =====\n");
try (Statement st = conn.createStatement()) {
ResultSet rs = st.executeQuery(
"SELECT COUNT(*) AS total, SUM(pay_time IS NULL) AS pay_time_null, SUM(pay_time IS NOT NULL) AS pay_time_not_null FROM dwd_trade_order_detail");
if (rs.next()) {
sb.append(String.format(" total=%s, pay_time_null=%s, pay_time_not_null=%s%n",
rs.getString("total"), rs.getString("pay_time_null"), rs.getString("pay_time_not_null")));
}
}
sb.append("\n===== 6. Sample: orders where pay_time IS NULL =====\n");
try (Statement st = conn.createStatement()) {
ResultSet rs = st.executeQuery(
"SELECT tid, oid, pay_time, created_time, status FROM dwd_trade_order_detail WHERE pay_time IS NULL LIMIT 5");
int row = 0;
while (rs.next()) {
sb.append(String.format(" [%d] tid=%s, status=%s, created=%s%n",
row++, rs.getString("tid"), rs.getString("status"), rs.getString("created_time")));
}
}
}
// Write to file
String output = sb.toString();
try (Writer w = new OutputStreamWriter(new FileOutputStream("customer_diag_output.txt"), StandardCharsets.UTF_8)) {
w.write(output);
}
System.out.println("Output written to customer_diag_output.txt");
System.out.println(output);
}
}

View File

@@ -0,0 +1,16 @@
package com.chuyishidai.datahub;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@MapperScan("com.chuyishidai.datahub.mapper")
public class DataHubApplication {
public static void main(String[] args) {
SpringApplication.run(DataHubApplication.class, args);
}
}

View File

@@ -0,0 +1,153 @@
package com.chuyishidai.datahub;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
/**
* 诊断: item_id 比对
* 拉取3条最近订单的原始JSON打印每个商品的 item_id + title
*
* Run: mvn -q compile exec:java -Dexec.mainClass="com.chuyishidai.datahub.ItemIdDiagTest"
* Output: item_id_diag_output.txt
*/
public class ItemIdDiagTest {
static final String CLIENT_ID = "f6eb734c712329dc5e";
static final String CLIENT_SECRET = "f7fd462479075645d38a460ceb1c2e47";
static final String GRANT_ID = "154234003";
static final String BASE_URL = "https://open.youzanyun.com";
static final String OUTPUT_FILE = "item_id_diag_output.txt";
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
RestTemplate rest = new RestTemplate();
StringBuilder sb = new StringBuilder();
String accessToken = getToken(rest, mapper);
sb.append("access_token: ").append(accessToken.substring(0, 10)).append("...\n\n");
// 拉取最近7天的前5条订单
String startTime = LocalDate.now().minusDays(7) + " 00:00:00";
String endTime = LocalDate.now().plusDays(1) + " 00:00:00";
sb.append("=" .repeat(70)).append("\n");
sb.append("Part 1: 有赞API返回的订单中每个商品明细的 item_id + title\n");
sb.append("=" .repeat(70)).append("\n");
String url = BASE_URL + "/api/youzan.trades.sold.get/4.0.4"
+ "?access_token=" + accessToken
+ "&start_created=" + startTime.replace(" ", "%20")
+ "&end_created=" + endTime.replace(" ", "%20")
+ "&page_no=1&page_size=5";
try {
java.net.URI uri = new java.net.URI(url);
String body = rest.getForObject(uri, String.class);
JsonNode root = mapper.readTree(body);
JsonNode data = root.get("data");
if (data == null) {
sb.append("data is null!\n");
} else {
JsonNode list = data.get("full_order_info_list");
if (list != null) {
for (int i = 0; i < list.size(); i++) {
JsonNode fullOrder = list.get(i).get("full_order_info");
if (fullOrder == null) continue;
JsonNode orderInfo = fullOrder.get("order_info");
String tid = orderInfo != null ? getText(orderInfo, "tid") : "?";
sb.append(String.format("\n--- 订单 #%d tid=%s ---\n", i + 1, tid));
JsonNode orders = fullOrder.get("orders");
if (orders != null) {
for (JsonNode item : orders) {
long itemId = item.has("item_id") ? item.get("item_id").asLong() : 0;
long skuId = item.has("sku_id") ? item.get("sku_id").asLong() : 0;
String title = getText(item, "title");
String outerItemId = getText(item, "outer_item_id");
String outerSkuId = getText(item, "outer_sku_id");
int num = item.has("num") ? item.get("num").asInt() : 0;
sb.append(String.format(
" item_id=%-15d sku_id=%-15d title=%-30s outer_item_id=%-15s outer_sku_id=%-15s num=%d\n",
itemId, skuId,
title != null ? title : "(null)",
outerItemId != null ? outerItemId : "(null)",
outerSkuId != null ? outerSkuId : "(null)",
num));
}
}
}
}
}
} catch (Exception e) {
sb.append("API调用异常: ").append(e.getMessage()).append("\n");
}
// Part 2: 同时把第一条订单的完整JSON也打印出来供人工检查
sb.append("\n\n");
sb.append("=" .repeat(70)).append("\n");
sb.append("Part 2: 第1条订单的完整原始JSON (供比对字段结构)\n");
sb.append("=" .repeat(70)).append("\n");
try {
java.net.URI uri = new java.net.URI(BASE_URL + "/api/youzan.trades.sold.get/4.0.4"
+ "?access_token=" + accessToken
+ "&start_created=" + startTime.replace(" ", "%20")
+ "&end_created=" + endTime.replace(" ", "%20")
+ "&page_no=1&page_size=1");
String body = rest.getForObject(uri, String.class);
JsonNode root = mapper.readTree(body);
JsonNode data = root.get("data");
if (data != null) {
JsonNode list = data.get("full_order_info_list");
if (list != null && !list.isEmpty()) {
String prettyJson = mapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(list.get(0));
sb.append(prettyJson);
}
}
} catch (Exception e) {
sb.append("Error: ").append(e.getMessage());
}
writeOutput(sb);
}
static String getText(JsonNode node, String field) {
return (node != null && node.has(field) && !node.get(field).isNull())
? node.get(field).asText() : null;
}
static String getToken(RestTemplate restTemplate, ObjectMapper mapper) throws Exception {
String url = BASE_URL + "/auth/token";
Map<String, Object> body = Map.of(
"client_id", CLIENT_ID,
"client_secret", CLIENT_SECRET,
"authorize_type", "silent",
"grant_id", GRANT_ID,
"refresh", false);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(mapper.writeValueAsString(body), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
JsonNode root = mapper.readTree(response.getBody());
return root.get("data").get("access_token").asText();
}
static void writeOutput(StringBuilder sb) throws Exception {
String output = sb.toString();
try (Writer w = new OutputStreamWriter(new FileOutputStream(OUTPUT_FILE), StandardCharsets.UTF_8)) {
w.write(output);
}
System.out.println("Output written to " + OUTPUT_FILE);
System.out.println(output);
}
}

View File

@@ -0,0 +1,102 @@
package com.chuyishidai.datahub;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
/**
* 诊断退款API实际返回字段
* 输出: server/refund_diag_output.txt
*/
public class RefundDiagTest {
private static final ObjectMapper mapper = new ObjectMapper();
private static final String BASE_URL = "https://open.youzanyun.com";
public static void main(String[] args) throws Exception {
StringBuilder sb = new StringBuilder();
RestTemplate rest = new RestTemplate();
// Step 1: Get token
String clientId = "1649a25ab648e0f9b6";
String clientSecret = "9e83198a1e4e3ce5c8bc2a07870c6bbe";
String kdtId = "113735809";
Map<String, String> tokenBody = Map.of(
"client_id", clientId,
"client_secret", clientSecret,
"grant_type", "silent",
"kdt_id", kdtId
);
HttpHeaders h = new HttpHeaders();
h.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
StringBuilder form = new StringBuilder();
tokenBody.forEach((k, v) -> {
if (form.length() > 0) form.append("&");
form.append(k).append("=").append(v);
});
String tokenResp = rest.postForObject(BASE_URL + "/auth/token",
new HttpEntity<>(form.toString(), h), String.class);
JsonNode tokenNode = mapper.readTree(tokenResp);
String token = tokenNode.get("data").get("access_token").asText();
sb.append("Token: ").append(token.substring(0, 20)).append("...\n\n");
// Step 2: Call refund API
LocalDateTime end = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0).withNano(0);
LocalDateTime start = end.minusDays(1);
long startEpoch = start.atZone(ZoneId.systemDefault()).toEpochSecond();
long endEpoch = end.atZone(ZoneId.systemDefault()).toEpochSecond();
Map<String, Object> params = new LinkedHashMap<>();
params.put("create_time_start", startEpoch);
params.put("create_time_end", endEpoch);
params.put("page_no", 1);
params.put("page_size", 3);
String url = BASE_URL + "/api/youzan.trade.refund.search/3.0.1?access_token=" + token;
HttpHeaders jsonHeaders = new HttpHeaders();
jsonHeaders.setContentType(MediaType.APPLICATION_JSON);
String body = mapper.writeValueAsString(params);
sb.append("Request URL: ").append(url.replace(token, "***")).append("\n");
sb.append("Request Body: ").append(body).append("\n\n");
String respBody = rest.postForObject(url, new HttpEntity<>(body, jsonHeaders), String.class);
JsonNode root = mapper.readTree(respBody);
sb.append("===== Full Response (pretty) =====\n");
sb.append(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root));
sb.append("\n\n");
// Step 3: List all fields from first refund
JsonNode data = root.get("data");
if (data != null) {
sb.append("total = ").append(data.has("total") ? data.get("total").asInt() : "N/A").append("\n\n");
JsonNode refunds = data.get("refunds");
if (refunds != null && refunds.isArray() && !refunds.isEmpty()) {
sb.append("===== First refund: all fields =====\n");
JsonNode first = refunds.get(0);
Iterator<Map.Entry<String, JsonNode>> fields = first.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> entry = fields.next();
String val = entry.getValue().isNull() ? "null"
: entry.getValue().isTextual() ? "\"" + entry.getValue().asText() + "\""
: entry.getValue().toString();
sb.append(String.format(" %-30s = %s%n", entry.getKey(), val));
}
}
}
// Write to file
try (Writer w = new OutputStreamWriter(new FileOutputStream("refund_diag_output.txt"), StandardCharsets.UTF_8)) {
w.write(sb.toString());
}
System.out.println("Written to refund_diag_output.txt");
}
}

View File

@@ -0,0 +1,252 @@
package com.chuyishidai.datahub;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* Standalone test: debug SCRM customer detail API
* Run: mvn -q compile exec:java
* -Dexec.mainClass="com.chuyishidai.datahub.YouzanCustomerTest"
* Output: server/customer_test_output.txt (UTF-8)
*/
public class YouzanCustomerTest {
static final String CLIENT_ID = "f6eb734c712329dc5e";
static final String CLIENT_SECRET = "f7fd462479075645d38a460ceb1c2e47";
static final String GRANT_ID = "154234003";
static final String BASE_URL = "https://open.youzanyun.com";
static final String OUTPUT_FILE = "customer_test_output.txt";
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
RestTemplate restTemplate = new RestTemplate();
StringBuilder sb = new StringBuilder();
// ===== Step 1: Get Token =====
sb.append("===== Step 1: Get Token =====\n");
String accessToken = getToken(restTemplate, mapper);
sb.append("access_token: ").append(accessToken).append("\n\n");
// ===== Step 2: 从订单中获取一个 yz_open_id =====
sb.append("===== Step 2: Get a yz_open_id from trade API =====\n");
String yzOpenId = getYzOpenIdFromTrade(restTemplate, mapper, accessToken, sb);
sb.append("yz_open_id from order: ").append(yzOpenId).append("\n\n");
if (yzOpenId == null || yzOpenId.isEmpty()) {
sb.append("ERROR: No yz_open_id found from orders, cannot test SCRM API\n");
writeOutput(sb);
return;
}
// ===== Step 3A: Test scrm.customer.detail.get with POST =====
sb.append("===== Step 3A: POST youzan.scrm.customer.detail.get/1.0.1 =====\n");
testScrmDetailPost(restTemplate, mapper, accessToken, yzOpenId, sb);
// ===== Step 3B: Test old scrm.customer.get with POST (for comparison) =====
sb.append("===== Step 3B: POST youzan.scrm.customer.get/3.0.0 (old API) =====\n");
testOldScrmPost(restTemplate, mapper, accessToken, yzOpenId, sb);
// ===== Step 3C: Test scrm.customer.detail.get with GET (文档说不行,验证一下) =====
sb.append("===== Step 3C: GET youzan.scrm.customer.detail.get/1.0.1 (should fail per doc) =====\n");
testScrmDetailGet(restTemplate, mapper, accessToken, yzOpenId, sb);
// ===== Step 4: 用手机号查询 (account_info) =====
sb.append("===== Step 4: POST scrm.customer.detail.get with account_info =====\n");
testScrmByAccount(restTemplate, mapper, accessToken, sb);
writeOutput(sb);
}
static void testScrmDetailPost(RestTemplate rest, ObjectMapper mapper, String token, String yzOpenId, StringBuilder sb) {
String url = BASE_URL + "/api/youzan.scrm.customer.detail.get/1.0.1?access_token=" + token;
// Test with fields
Map<String, Object> params = new LinkedHashMap<>();
params.put("yz_open_id", yzOpenId);
params.put("fields", "user_base,tags,benefit_level,behavior,credit");
sb.append("URL: ").append(url).append("\n");
try {
String bodyJson = mapper.writeValueAsString(params);
sb.append("Request Body: ").append(bodyJson).append("\n");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(bodyJson, headers);
ResponseEntity<String> resp = rest.postForEntity(url, request, String.class);
sb.append("Status: ").append(resp.getStatusCode()).append("\n");
JsonNode root = mapper.readTree(resp.getBody());
sb.append("Response:\n").append(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root));
sb.append("\n\n");
// 解析关键信息
if (root.has("data") && root.get("data") != null && !root.get("data").isNull()) {
JsonNode data = root.get("data");
sb.append("--- data 内包含的 key: ").append(fieldNames(data)).append("\n");
if (data.has("user_base")) {
sb.append("--- user_base: ").append(mapper.writeValueAsString(data.get("user_base"))).append("\n");
} else {
sb.append("--- user_base: NOT FOUND\n");
}
if (data.has("behavior")) {
sb.append("--- behavior: ").append(mapper.writeValueAsString(data.get("behavior"))).append("\n");
} else {
sb.append("--- behavior: NOT FOUND\n");
}
if (data.has("tags")) {
sb.append("--- tags: ").append(mapper.writeValueAsString(data.get("tags"))).append("\n");
} else {
sb.append("--- tags: NOT FOUND\n");
}
} else {
sb.append("--- data is null or missing!\n");
}
sb.append("\n");
} catch (Exception e) {
sb.append("Error: ").append(e.getClass().getSimpleName()).append(" - ").append(e.getMessage()).append("\n\n");
}
}
static void testOldScrmPost(RestTemplate rest, ObjectMapper mapper, String token, String yzOpenId, StringBuilder sb) {
String url = BASE_URL + "/api/youzan.scrm.customer.get/3.0.0?access_token=" + token;
Map<String, Object> params = new LinkedHashMap<>();
params.put("yz_open_id", yzOpenId);
try {
String bodyJson = mapper.writeValueAsString(params);
sb.append("URL: ").append(url).append("\n");
sb.append("Request Body: ").append(bodyJson).append("\n");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(bodyJson, headers);
ResponseEntity<String> resp = rest.postForEntity(url, request, String.class);
sb.append("Status: ").append(resp.getStatusCode()).append("\n");
JsonNode root = mapper.readTree(resp.getBody());
sb.append("Response:\n").append(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root)).append("\n\n");
} catch (Exception e) {
sb.append("Error: ").append(e.getClass().getSimpleName()).append(" - ").append(e.getMessage()).append("\n\n");
}
}
static void testScrmDetailGet(RestTemplate rest, ObjectMapper mapper, String token, String yzOpenId, StringBuilder sb) {
String url = BASE_URL + "/api/youzan.scrm.customer.detail.get/1.0.1"
+ "?access_token=" + token
+ "&yz_open_id=" + yzOpenId
+ "&fields=user_base,tags,behavior";
sb.append("URL: ").append(url).append("\n");
try {
java.net.URI uri = new java.net.URI(url);
ResponseEntity<String> resp = rest.exchange(uri, HttpMethod.GET, null, String.class);
sb.append("Status: ").append(resp.getStatusCode()).append("\n");
JsonNode root = mapper.readTree(resp.getBody());
sb.append("Response:\n").append(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root)).append("\n\n");
} catch (Exception e) {
sb.append("Error: ").append(e.getClass().getSimpleName()).append(" - ").append(e.getMessage()).append("\n\n");
}
}
static void testScrmByAccount(RestTemplate rest, ObjectMapper mapper, String token, StringBuilder sb) {
String url = BASE_URL + "/api/youzan.scrm.customer.detail.get/1.0.1?access_token=" + token;
// 用手机号查询
Map<String, Object> accountInfo = new LinkedHashMap<>();
accountInfo.put("account_id", "13800138000"); // 示例手机号,实际需要替换
accountInfo.put("account_type", 2);
Map<String, Object> params = new LinkedHashMap<>();
params.put("account_info", accountInfo);
params.put("fields", "user_base,tags,behavior");
try {
String bodyJson = mapper.writeValueAsString(params);
sb.append("URL: ").append(url).append("\n");
sb.append("Request Body: ").append(bodyJson).append("\n");
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(bodyJson, headers);
ResponseEntity<String> resp = rest.postForEntity(url, request, String.class);
sb.append("Status: ").append(resp.getStatusCode()).append("\n");
JsonNode root = mapper.readTree(resp.getBody());
sb.append("Response:\n").append(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root)).append("\n\n");
} catch (Exception e) {
sb.append("Error: ").append(e.getClass().getSimpleName()).append(" - ").append(e.getMessage()).append("\n\n");
}
}
/**
* 从订单API中获取第一个有 yz_open_id 的买家
*/
static String getYzOpenIdFromTrade(RestTemplate rest, ObjectMapper mapper, String token, StringBuilder sb) {
String url = BASE_URL + "/api/youzan.trades.sold.get/4.0.4"
+ "?access_token=" + token
+ "&page_no=1&page_size=5";
try {
java.net.URI uri = new java.net.URI(url);
ResponseEntity<String> resp = rest.exchange(uri, HttpMethod.GET, null, String.class);
JsonNode root = mapper.readTree(resp.getBody());
JsonNode data = root.get("data");
if (data == null) return null;
JsonNode list = data.get("full_order_info_list");
if (list == null || !list.isArray()) return null;
for (JsonNode wrapper : list) {
JsonNode fullOrder = wrapper.get("full_order_info");
if (fullOrder == null) continue;
JsonNode buyerInfo = fullOrder.get("buyer_info");
if (buyerInfo == null) continue;
String openId = buyerInfo.has("yz_open_id") ? buyerInfo.get("yz_open_id").asText() : null;
if (openId != null && !openId.isEmpty()) {
sb.append("Found yz_open_id from order: ").append(openId).append("\n");
// 也打印买家其他信息
sb.append("buyer_info: ").append(mapper.writeValueAsString(buyerInfo)).append("\n");
return openId;
}
}
} catch (Exception e) {
sb.append("Trade API error: ").append(e.getMessage()).append("\n");
}
return null;
}
static String getToken(RestTemplate restTemplate, ObjectMapper mapper) throws Exception {
String url = BASE_URL + "/auth/token";
Map<String, Object> body = Map.of(
"client_id", CLIENT_ID,
"client_secret", CLIENT_SECRET,
"authorize_type", "silent",
"grant_id", GRANT_ID,
"refresh", false);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(mapper.writeValueAsString(body), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
JsonNode root = mapper.readTree(response.getBody());
return root.get("data").get("access_token").asText();
}
static String fieldNames(JsonNode node) {
List<String> names = new ArrayList<>();
node.fieldNames().forEachRemaining(names::add);
return String.join(", ", names);
}
static void writeOutput(StringBuilder sb) throws Exception {
String output = sb.toString();
try (Writer w = new OutputStreamWriter(new FileOutputStream(OUTPUT_FILE), StandardCharsets.UTF_8)) {
w.write(output);
}
System.out.println("Output written to " + OUTPUT_FILE);
System.out.println(output);
}
}

View File

@@ -0,0 +1,273 @@
package com.chuyishidai.datahub;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
/**
* Diagnostic test: 分析订单数量不一致问题
* 有赞控制台 2521 单 vs 同步程序 147 单
*
* 检查点:
* 1. start_update/end_update vs start_created/end_created 的差异
* 2. total_results 和 has_next 的值
* 3. 每页返回的订单数
* 4. 连锁门店是否需要分店查询
*
* Run: mvn -q compile exec:java
* -Dexec.mainClass="com.chuyishidai.datahub.YouzanOrderDiagTest"
* Output: server/order_diag_output.txt
*/
public class YouzanOrderDiagTest {
static final String CLIENT_ID = "f6eb734c712329dc5e";
static final String CLIENT_SECRET = "f7fd462479075645d38a460ceb1c2e47";
static final String GRANT_ID = "154234003";
static final String BASE_URL = "https://open.youzanyun.com";
static final String OUTPUT_FILE = "order_diag_output.txt";
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
RestTemplate rest = new RestTemplate();
StringBuilder sb = new StringBuilder();
String accessToken = getToken(rest, mapper);
sb.append("access_token: ").append(accessToken).append("\n\n");
// 昨天的日期
LocalDate yesterday = LocalDate.now().minusDays(1);
String startTime = yesterday + " 00:00:00";
String endTime = yesterday.plusDays(1) + " 00:00:00";
sb.append("查询日期范围: ").append(startTime).append(" ~ ").append(endTime).append("\n\n");
// ===== Test 1: 用 start_update / end_update (当前代码使用的方式) =====
sb.append("=" .repeat(60)).append("\n");
sb.append("Test 1: start_update / end_update (当前代码使用的方式)\n");
sb.append("=" .repeat(60)).append("\n");
testQuery(rest, mapper, accessToken, Map.of(
"start_update", startTime,
"end_update", endTime,
"page_no", 1,
"page_size", 100
), sb);
// ===== Test 2: 用 start_created / end_created (按创建时间) =====
sb.append("=" .repeat(60)).append("\n");
sb.append("Test 2: start_created / end_created (按创建时间)\n");
sb.append("=" .repeat(60)).append("\n");
testQuery(rest, mapper, accessToken, Map.of(
"start_created", startTime,
"end_created", endTime,
"page_no", 1,
"page_size", 100
), sb);
// ===== Test 3: 无时间过滤,看总量 =====
sb.append("=" .repeat(60)).append("\n");
sb.append("Test 3: 无时间过滤 (page_size=5, 只看 total_results)\n");
sb.append("=" .repeat(60)).append("\n");
testQuery(rest, mapper, accessToken, Map.of(
"page_no", 1,
"page_size", 5
), sb);
// ===== Test 4: 用 start_created 拉取多页,统计实际总数 =====
sb.append("=" .repeat(60)).append("\n");
sb.append("Test 4: start_created 拉取全量 (page_size=100, 遍历所有页)\n");
sb.append("=" .repeat(60)).append("\n");
int totalOrders = 0;
int totalDetails = 0;
int pageNo = 1;
boolean hasNext = true;
while (hasNext && pageNo <= 100) {
String url = BASE_URL + "/api/youzan.trades.sold.get/4.0.4"
+ "?access_token=" + accessToken
+ "&start_created=" + startTime.replace(" ", "%20")
+ "&end_created=" + endTime.replace(" ", "%20")
+ "&page_no=" + pageNo
+ "&page_size=100";
try {
java.net.URI uri = new java.net.URI(url);
String body = rest.getForObject(uri, String.class);
JsonNode root = mapper.readTree(body);
JsonNode data = root.get("data");
if (data == null) { sb.append("Page ").append(pageNo).append(": data is null\n"); break; }
int totalResults = data.has("total_results") ? data.get("total_results").asInt(0) : -1;
hasNext = data.has("has_next") && data.get("has_next").asBoolean(false);
JsonNode list = data.get("full_order_info_list");
int ordersOnPage = (list != null) ? list.size() : 0;
// 统计每个订单的明细数
int detailsOnPage = 0;
if (list != null) {
for (JsonNode wrapper : list) {
JsonNode fullOrder = wrapper.get("full_order_info");
if (fullOrder != null && fullOrder.has("orders")) {
detailsOnPage += fullOrder.get("orders").size();
}
}
}
totalOrders += ordersOnPage;
totalDetails += detailsOnPage;
sb.append(String.format(" Page %d: %d orders, %d details, total_results=%d, has_next=%s\n",
pageNo, ordersOnPage, detailsOnPage, totalResults, hasNext));
if (ordersOnPage == 0) break;
pageNo++;
} catch (Exception e) {
sb.append(" Page ").append(pageNo).append(" error: ").append(e.getMessage()).append("\n");
break;
}
}
sb.append(String.format("\n ==> 总计: %d 笔订单, %d 条明细 (遍历了 %d 页)\n\n", totalOrders, totalDetails, pageNo - 1));
// ===== Test 5: 检查 start_update 多页 =====
sb.append("=" .repeat(60)).append("\n");
sb.append("Test 5: start_update 拉取全量对比\n");
sb.append("=" .repeat(60)).append("\n");
totalOrders = 0;
totalDetails = 0;
pageNo = 1;
hasNext = true;
while (hasNext && pageNo <= 100) {
String url = BASE_URL + "/api/youzan.trades.sold.get/4.0.4"
+ "?access_token=" + accessToken
+ "&start_update=" + startTime.replace(" ", "%20")
+ "&end_update=" + endTime.replace(" ", "%20")
+ "&page_no=" + pageNo
+ "&page_size=100";
try {
java.net.URI uri = new java.net.URI(url);
String body = rest.getForObject(uri, String.class);
JsonNode root = mapper.readTree(body);
JsonNode data = root.get("data");
if (data == null) { sb.append(" Page ").append(pageNo).append(": data is null\n"); break; }
hasNext = data.has("has_next") && data.get("has_next").asBoolean(false);
JsonNode list = data.get("full_order_info_list");
int ordersOnPage = (list != null) ? list.size() : 0;
int detailsOnPage = 0;
if (list != null) {
for (JsonNode wrapper : list) {
JsonNode fullOrder = wrapper.get("full_order_info");
if (fullOrder != null && fullOrder.has("orders")) {
detailsOnPage += fullOrder.get("orders").size();
}
}
}
totalOrders += ordersOnPage;
totalDetails += detailsOnPage;
sb.append(String.format(" Page %d: %d orders, %d details, has_next=%s\n",
pageNo, ordersOnPage, detailsOnPage, hasNext));
if (ordersOnPage == 0) break;
pageNo++;
} catch (Exception e) {
sb.append(" Page ").append(pageNo).append(" error: ").append(e.getMessage()).append("\n");
break;
}
}
sb.append(String.format("\n ==> 总计: %d 笔订单, %d 条明细 (遍历了 %d 页)\n\n", totalOrders, totalDetails, pageNo - 1));
writeOutput(sb);
}
static void testQuery(RestTemplate rest, ObjectMapper mapper, String token, Map<String, Object> params, StringBuilder sb) {
StringBuilder urlBuilder = new StringBuilder(BASE_URL + "/api/youzan.trades.sold.get/4.0.4?access_token=" + token);
params.forEach((k, v) -> urlBuilder.append("&").append(k).append("=").append(v.toString()));
String rawUrl = urlBuilder.toString();
sb.append("URL: ").append(rawUrl).append("\n");
try {
java.net.URI uri = new java.net.URI(rawUrl.replace(" ", "%20"));
String body = rest.getForObject(uri, String.class);
JsonNode root = mapper.readTree(body);
// 检查错误
if (root.has("gw_err_resp")) {
sb.append("GW Error: ").append(root.get("gw_err_resp")).append("\n\n");
return;
}
boolean success = root.has("success") && root.get("success").asBoolean(false);
int code = root.has("code") ? root.get("code").asInt() : -1;
sb.append("success=").append(success).append(", code=").append(code).append("\n");
JsonNode data = root.get("data");
if (data != null) {
int totalResults = data.has("total_results") ? data.get("total_results").asInt(0) : -1;
boolean hasNext = data.has("has_next") && data.get("has_next").asBoolean(false);
JsonNode list = data.get("full_order_info_list");
int ordersOnPage = (list != null) ? list.size() : 0;
sb.append("total_results: ").append(totalResults).append("\n");
sb.append("has_next: ").append(hasNext).append("\n");
sb.append("orders on this page: ").append(ordersOnPage).append("\n");
// 打印前2个订单的基本信息
if (list != null && !list.isEmpty()) {
int showCount = Math.min(2, list.size());
for (int i = 0; i < showCount; i++) {
JsonNode wrapper = list.get(i);
JsonNode fullOrder = wrapper.get("full_order_info");
if (fullOrder != null) {
JsonNode orderInfo = fullOrder.get("order_info");
JsonNode orders = fullOrder.get("orders");
int detailCount = (orders != null) ? orders.size() : 0;
sb.append(String.format(" [%d] tid=%s, status=%s, created=%s, details=%d\n",
i,
orderInfo != null ? getText(orderInfo, "tid") : "?",
orderInfo != null ? getText(orderInfo, "status") : "?",
orderInfo != null ? getText(orderInfo, "created") : "?",
detailCount));
}
}
}
} else {
sb.append("data is null!\n");
}
sb.append("\n");
} catch (Exception e) {
sb.append("Error: ").append(e.getClass().getSimpleName()).append(" - ").append(e.getMessage()).append("\n\n");
}
}
static String getText(JsonNode node, String field) {
return (node != null && node.has(field)) ? node.get(field).asText() : null;
}
static String getToken(RestTemplate restTemplate, ObjectMapper mapper) throws Exception {
String url = BASE_URL + "/auth/token";
Map<String, Object> body = Map.of(
"client_id", CLIENT_ID,
"client_secret", CLIENT_SECRET,
"authorize_type", "silent",
"grant_id", GRANT_ID,
"refresh", false);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(mapper.writeValueAsString(body), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
JsonNode root = mapper.readTree(response.getBody());
return root.get("data").get("access_token").asText();
}
static void writeOutput(StringBuilder sb) throws Exception {
String output = sb.toString();
try (Writer w = new OutputStreamWriter(new FileOutputStream(OUTPUT_FILE), StandardCharsets.UTF_8)) {
w.write(output);
}
System.out.println("Output written to " + OUTPUT_FILE);
System.out.println(output);
}
}

View File

@@ -0,0 +1,83 @@
package com.chuyishidai.datahub;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* 独立测试脚本:直接调用有赞 Token API
* 运行方式: mvn exec:java -Dexec.mainClass="com.chuyishidai.datahub.YouzanTokenTest"
*/
public class YouzanTokenTest {
public static void main(String[] args) throws Exception {
String clientId = "f6eb734c712329dc5e";
String clientSecret = "f7fd462479075645d38a460ceb1c2e47";
String grantId = "155239704";
String url = "https://open.youzanyun.com/auth/token";
ObjectMapper mapper = new ObjectMapper();
RestTemplate restTemplate = new RestTemplate();
Map<String, Object> body = Map.of(
"client_id", clientId,
"client_secret", clientSecret,
"authorize_type", "silent",
"grant_id", grantId,
"refresh", true
);
String jsonBody = mapper.writeValueAsString(body);
System.out.println("========== 请求 ==========");
System.out.println("URL: " + url);
System.out.println("Body: " + jsonBody);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(jsonBody, headers);
try {
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
System.out.println("========== 响应 ==========");
System.out.println("Status: " + response.getStatusCode());
System.out.println("Body: " + response.getBody());
// 解析并显示 token 信息
var root = mapper.readTree(response.getBody());
if (root.has("data") && root.get("data").has("access_token")) {
var data = root.get("data");
System.out.println("========== Token 信息 ==========");
System.out.println("access_token: " + data.get("access_token").asText());
if (data.has("expires_in")) {
System.out.println("expires_in: " + data.get("expires_in").asLong() + "");
}
if (data.has("expires")) {
System.out.println("expires: " + data.get("expires").asLong() + " (毫秒时间戳)");
}
} else {
// 可能 token 直接在根节点
if (root.has("access_token")) {
System.out.println("========== Token 信息 (根节点) ==========");
System.out.println("access_token: " + root.get("access_token").asText());
if (root.has("expires")) {
System.out.println("expires: " + root.get("expires").asLong());
}
}
}
} catch (HttpClientErrorException | HttpServerErrorException e) {
System.out.println("========== HTTP 错误 ==========");
System.out.println("Status: " + e.getStatusCode());
System.out.println("Body: " + e.getResponseBodyAsString());
} catch (Exception e) {
System.out.println("========== 异常 ==========");
System.out.println("Type: " + e.getClass().getName());
System.out.println("Message: " + e.getMessage());
e.printStackTrace();
}
}
}

View File

@@ -0,0 +1,131 @@
package com.chuyishidai.datahub;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.*;
import org.springframework.web.client.RestTemplate;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* Standalone test: get token + call trade query API
* Run: mvn -q compile exec:java
* -Dexec.mainClass="com.chuyishidai.datahub.YouzanTradeTest"
* Output: server/trade_test_output.txt (UTF-8)
*/
public class YouzanTradeTest {
static final String CLIENT_ID = "f6eb734c712329dc5e";
static final String CLIENT_SECRET = "f7fd462479075645d38a460ceb1c2e47";
static final String GRANT_ID = "154234003";
static final String BASE_URL = "https://open.youzanyun.com";
static final String OUTPUT_FILE = "trade_test_output.txt";
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
RestTemplate restTemplate = new RestTemplate();
StringBuilder sb = new StringBuilder();
// ===== Step 1: Get Token =====
sb.append("===== Step 1: Get Token =====\n");
String accessToken = getToken(restTemplate, mapper);
sb.append("access_token: ").append(accessToken).append("\n\n");
// ===== Step 2: Call Trade API =====
// Try multiple approaches to find the one that works
// Approach A: GET with manually built URL (no Spring UriComponentsBuilder
// encoding)
sb.append("===== Step 2A: GET with raw URL =====\n");
String rawUrl = BASE_URL + "/api/youzan.trades.sold.get/4.0.4"
+ "?access_token=" + accessToken
+ "&start_created=2025-01-01 00:00:00"
+ "&end_created=2026-03-18 23:59:59"
+ "&page_no=1&page_size=5";
sb.append("URL: ").append(rawUrl).append("\n");
try {
// Use URI directly to avoid double encoding
java.net.URI uri = new java.net.URI(rawUrl.replace(" ", "%20"));
ResponseEntity<String> resp = restTemplate.exchange(uri, HttpMethod.GET, null, String.class);
sb.append("Status: ").append(resp.getStatusCode()).append("\n");
sb.append("Body: ").append(resp.getBody()).append("\n\n");
} catch (Exception e) {
sb.append("Error: ").append(e.getClass().getSimpleName()).append(" - ").append(e.getMessage())
.append("\n\n");
}
// Approach B: POST with JSON body (SDK style)
sb.append("===== Step 2B: POST with JSON body =====\n");
String postUrl = BASE_URL + "/api/youzan.trades.sold.get/4.0.4"
+ "?access_token=" + accessToken;
Map<String, Object> tradeParams = Map.of(
"start_created", "2025-01-01 00:00:00",
"end_created", "2026-03-18 23:59:59",
"page_no", 1,
"page_size", 5);
sb.append("URL: ").append(postUrl).append("\n");
sb.append("Body: ").append(mapper.writeValueAsString(tradeParams)).append("\n");
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(mapper.writeValueAsString(tradeParams), headers);
ResponseEntity<String> resp = restTemplate.postForEntity(postUrl, request, String.class);
sb.append("Status: ").append(resp.getStatusCode()).append("\n");
sb.append("Body: ").append(resp.getBody()).append("\n\n");
} catch (Exception e) {
sb.append("Error: ").append(e.getClass().getSimpleName()).append(" - ").append(e.getMessage())
.append("\n\n");
}
// Approach C: GET with only access_token, no date filter (simplest)
sb.append("===== Step 2C: GET minimal (no date filter) =====\n");
String minUrl = BASE_URL + "/api/youzan.trades.sold.get/4.0.4"
+ "?access_token=" + accessToken
+ "&page_no=1&page_size=2";
sb.append("URL: ").append(minUrl).append("\n");
try {
java.net.URI uri = new java.net.URI(minUrl);
ResponseEntity<String> resp = restTemplate.exchange(uri, HttpMethod.GET, null, String.class);
sb.append("Status: ").append(resp.getStatusCode()).append("\n");
String body = resp.getBody();
// Pretty print if JSON
try {
JsonNode root = mapper.readTree(body);
sb.append("Body:\n").append(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(root));
} catch (Exception e2) {
sb.append("Body: ").append(body);
}
sb.append("\n\n");
} catch (Exception e) {
sb.append("Error: ").append(e.getClass().getSimpleName()).append(" - ").append(e.getMessage())
.append("\n\n");
}
// Write output to file with UTF-8
String output = sb.toString();
try (Writer w = new OutputStreamWriter(new FileOutputStream(OUTPUT_FILE), StandardCharsets.UTF_8)) {
w.write(output);
}
System.out.println("Output written to " + OUTPUT_FILE);
System.out.println(output);
}
static String getToken(RestTemplate restTemplate, ObjectMapper mapper) throws Exception {
String url = BASE_URL + "/auth/token";
Map<String, Object> body = Map.of(
"client_id", CLIENT_ID,
"client_secret", CLIENT_SECRET,
"authorize_type", "silent",
"grant_id", GRANT_ID,
"refresh", false);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<String> request = new HttpEntity<>(mapper.writeValueAsString(body), headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
JsonNode root = mapper.readTree(response.getBody());
return root.get("data").get("access_token").asText();
}
}

View File

@@ -0,0 +1,19 @@
package com.chuyishidai.datahub.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}

View File

@@ -0,0 +1,33 @@
package com.chuyishidai.datahub.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
/**
* 有赞云 API 配置(自用型应用)
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "youzan.api")
public class YouzanConfig {
/** 有赞开放平台基础 URL */
private String baseUrl = "https://open.youzanyun.com";
/** 自用型应用 client_id */
private String clientId;
/** 自用型应用 client_secret */
private String clientSecret;
/** 店铺 kdt_id */
private String kdtId;
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@@ -0,0 +1,72 @@
package com.chuyishidai.datahub.controller;
import com.chuyishidai.datahub.service.sync.CustomerSyncService;
import com.chuyishidai.datahub.service.sync.RefundSyncService;
import com.chuyishidai.datahub.service.sync.TradeSyncService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 历史数据辅助运维接口
*/
@Slf4j
@RestController
@RequestMapping("/api/admin/backfill")
@RequiredArgsConstructor
public class AdminBackfillController {
private final TradeSyncService tradeSyncService;
private final RefundSyncService refundSyncService;
private final CustomerSyncService customerSyncService;
/**
* 自动从今年1月1日循环补全到今天的数据
* (按天切片以防有赞最大100页的强制限制)
*/
@GetMapping("/january-to-now")
public String backfillFromJanuary() {
new Thread(() -> {
log.info("========== 开始历史数据补全 (1月1日至今) ==========");
LocalDate start = LocalDate.of(LocalDate.now().getYear(), 1, 1);
LocalDate end = LocalDate.now();
int totalTrades = 0;
int totalRefunds = 0;
// Step 1: 按天拉取订单和退款
for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
LocalDateTime startOfDay = date.atStartOfDay();
LocalDateTime endOfDay = date.atTime(LocalTime.MAX);
log.info(">> 正在补全 {} 的有赞数据...", date);
try {
int trades = tradeSyncService.syncTradesByUpdateTime(startOfDay, endOfDay, 100);
int refunds = refundSyncService.syncRefundsByTime(startOfDay, endOfDay);
totalTrades += trades;
totalRefunds += refunds;
Thread.sleep(300);
} catch (Exception e) {
log.error("补全 {} 数据异常: {}", date, e.getMessage());
}
}
log.info("========== 订单/退款拉取完毕! 订单明细 {} 条,退款单 {} 条 ==========", totalTrades, totalRefunds);
// Step 2: 从已入库的订单数据聚合客户画像
log.info(">> 开始从订单数据聚合客户信息...");
int customers = customerSyncService.syncCustomersFromOrders();
log.info("========== 全部历史数据补全完成! 订单 {} 条, 退款 {} 条, 客户 {} 条 ==========", totalTrades, totalRefunds, customers);
}, "Backfill-Thread").start();
return "历史数据补全跑批任务已于后台启动!流程: ①按天拉取1月至今的订单+退款 → ②从订单聚合客户画像。请在服务端控制台查看进度。";
}
}

View File

@@ -0,0 +1,139 @@
package com.chuyishidai.datahub.controller;
import com.chuyishidai.datahub.job.InsightComputeJob;
import com.chuyishidai.datahub.job.Phase3SyncJob;
import com.chuyishidai.datahub.job.TradeSyncJob;
import com.chuyishidai.datahub.service.sync.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 运维操作接口 — 各同步/计算任务的手动触发入口
*/
@Slf4j
@RestController
@RequestMapping("/api/ops")
@RequiredArgsConstructor
public class AdminOpsController {
private final TradeSyncJob tradeSyncJob;
private final Phase3SyncJob phase3SyncJob;
private final ItemSyncService itemSyncService;
private final StoreSyncService storeSyncService;
private final InventorySyncService inventorySyncService;
private final CustomerSyncService customerSyncService;
private final InsightComputeJob insightComputeJob;
/**
* 订单同步 (智能增量 / 指定日期)
* @param from 可选, 格式 yyyy-MM-dd, 从该日期开始同步; 不传则自动检测缺口
*/
@GetMapping("/sync-orders")
public Map<String, Object> syncOrders(
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from) {
try {
TradeSyncJob.SyncResult result = tradeSyncJob.smartSync(from);
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("status", "success");
resp.put("task", "订单同步");
resp.put("count", result.count());
resp.put("message", String.format("窗口 %s → %s, 处理 %d 条, 耗时 %ds",
result.windowStart(), result.windowEnd(), result.count(), result.elapsedSeconds()));
return resp;
} catch (Exception e) {
log.error("[AdminOps] 订单同步异常", e);
return Map.of("status", "error", "task", "订单同步", "message", e.getMessage());
}
}
/**
* 退款同步 (智能增量 / 指定日期)
* @param from 可选, 格式 yyyy-MM-dd, 从该日期开始同步; 不传则自动检测缺口
*/
@GetMapping("/sync-refunds")
public Map<String, Object> syncRefunds(
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from) {
try {
Phase3SyncJob.RefundSyncResult result = phase3SyncJob.smartRefundSync(from);
Map<String, Object> resp = new LinkedHashMap<>();
resp.put("status", "success");
resp.put("task", "退款同步");
resp.put("count", result.count());
resp.put("message", String.format("窗口 %s → %s, 处理 %d 条, 耗时 %ds",
result.windowStart(), result.windowEnd(), result.count(), result.elapsedSeconds()));
return resp;
} catch (Exception e) {
log.error("[AdminOps] 退款同步异常", e);
return Map.of("status", "error", "task", "退款同步", "message", e.getMessage());
}
}
/** 商品同步 (全量) */
@GetMapping("/sync-items")
public Map<String, Object> syncItems() {
try {
int count = itemSyncService.syncAllItems();
return Map.of("status", "success", "task", "商品同步", "count", count);
} catch (Exception e) {
log.error("[AdminOps] 商品同步异常", e);
return Map.of("status", "error", "task", "商品同步", "message", e.getMessage());
}
}
/** 门店同步 */
@GetMapping("/sync-stores")
public Map<String, Object> syncStores() {
try {
int count = storeSyncService.syncStores();
return Map.of("status", "success", "task", "门店同步", "count", count);
} catch (Exception e) {
log.error("[AdminOps] 门店同步异常", e);
return Map.of("status", "error", "task", "门店同步", "message", e.getMessage());
}
}
/** 库存快照同步 */
@GetMapping("/sync-inventory")
public Map<String, Object> syncInventory() {
try {
int count = inventorySyncService.syncInventorySnapshot();
return Map.of("status", "success", "task", "库存快照同步", "count", count);
} catch (Exception e) {
log.error("[AdminOps] 库存快照异常", e);
return Map.of("status", "error", "task", "库存快照同步", "message", e.getMessage());
}
}
/** 客户聚合 (从订单表) */
@GetMapping("/sync-customers")
public Map<String, Object> syncCustomers() {
try {
int count = customerSyncService.syncCustomersFromOrders();
return Map.of("status", "success", "task", "客户聚合", "count", count);
} catch (Exception e) {
log.error("[AdminOps] 客户聚合异常", e);
return Map.of("status", "error", "task", "客户聚合", "message", e.getMessage());
}
}
/** 洞察计算 (全量) */
@GetMapping("/compute-insights")
public Map<String, Object> computeInsights() {
try {
insightComputeJob.triggerInsightComputations();
return Map.of("status", "success", "task", "洞察计算", "message", "全域洞察计算完成");
} catch (Exception e) {
log.error("[AdminOps] 洞察计算异常", e);
return Map.of("status", "error", "task", "洞察计算", "message", e.getMessage());
}
}
}

View File

@@ -0,0 +1,53 @@
package com.chuyishidai.datahub.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/customer")
@RequiredArgsConstructor
public class CustomerController {
private final JdbcTemplate jdbcTemplate;
/**
* RFM 客群金字塔分布统计
*/
@GetMapping("/rfm/distribution")
public List<Map<String, Object>> getRfmDistribution() {
String sql = """
SELECT
rfm_group,
COUNT(yz_open_id) AS customer_count,
SUM(trade_amount_90d) AS total_revenue_contribution,
-- 算一下客均贡献
SUM(trade_amount_90d) / COUNT(yz_open_id) AS avg_contribution
FROM adm_customer_rfm
GROUP BY rfm_group
ORDER BY total_revenue_contribution DESC
""";
return jdbcTemplate.queryForList(sql);
}
/**
* 各类人群标签统计
*/
@GetMapping("/tags")
public List<Map<String, Object>> getTagsDistribution() {
String sql = """
SELECT
tag_name,
COUNT(yz_open_id) AS tagged_users_count
FROM adm_customer_tags
GROUP BY tag_name
ORDER BY tagged_users_count DESC
""";
return jdbcTemplate.queryForList(sql);
}
}

View File

@@ -0,0 +1,107 @@
package com.chuyishidai.datahub.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final JdbcTemplate jdbcTemplate;
/**
* 过去14天大盘概览
*/
@GetMapping("/overview")
public Map<String, Object> getOverview() {
// 过去 14 天订单数, 实付总额
String orderSql = """
SELECT
COUNT(DISTINCT tid) AS total_orders,
COALESCE(SUM(payment), 0) AS total_revenue
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 14 DAY)
""";
Map<String, Object> orderStats = jdbcTemplate.queryForMap(orderSql);
// 过去 14 天退款总额
String refundSql = """
SELECT
COALESCE(SUM(refund_fee), 0) AS total_refund
FROM dwd_trade_refund_detail
WHERE created_time >= DATE_SUB(CURDATE(), INTERVAL 14 DAY)
""";
Map<String, Object> refundStats = jdbcTemplate.queryForMap(refundSql);
// 汇总指标
Number revenue = (Number) orderStats.getOrDefault("total_revenue", 0);
Number orders = (Number) orderStats.getOrDefault("total_orders", 0);
Number refunds = (Number) refundStats.getOrDefault("total_refund", 0);
// 也可以顺便算一下客单价
double atv = (orders.intValue() > 0) ? (revenue.doubleValue() / orders.doubleValue()) : 0.0;
return Map.of(
"past_14d_revenue", revenue,
"past_14d_refund", refunds,
"past_14d_orders", orders,
"past_14d_atv", String.format("%.2f", atv)
);
}
/**
* 14 天整体销售趋势
*/
@GetMapping("/trend")
public List<Map<String, Object>> getSalesTrend() {
String sql = """
SELECT
DATE(pay_time) AS stat_date,
COUNT(DISTINCT tid) AS order_count,
COALESCE(SUM(payment), 0) AS revenue
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 14 DAY)
GROUP BY DATE(pay_time)
ORDER BY stat_date ASC
""";
return jdbcTemplate.queryForList(sql);
}
/**
* 各页面数据截止时间
* 前端在每个页面标题旁展示
*/
@GetMapping("/data-cutoff")
public Map<String, Object> getDataCutoff() {
// 订单: 大盘总览 / 订货建议 的数据基础
String orderCutoff = safeQueryDate("SELECT DATE_FORMAT(MAX(pay_time), '%Y年%m月%d日') FROM dwd_trade_order_detail WHERE status = 'TRADE_SUCCESS'");
// 退款
String refundCutoff = safeQueryDate("SELECT DATE_FORMAT(MAX(created_time), '%Y年%m月%d日') FROM dwd_trade_refund_detail");
// 商品洞察 (复购/购物篮): 基于 insight 表
String insightCutoff = safeQueryDate("SELECT DATE_FORMAT(MAX(etl_update_time), '%Y年%m月%d日') FROM adm_item_repurchase");
// 会员画像: RFM
String rfmCutoff = safeQueryDate("SELECT DATE_FORMAT(MAX(etl_update_time), '%Y年%m月%d日') FROM adm_customer_rfm");
return Map.of(
"overview", orderCutoff != null ? orderCutoff : "无数据",
"product", insightCutoff != null ? insightCutoff : "无数据",
"customer", rfmCutoff != null ? rfmCutoff : "无数据",
"advice", orderCutoff != null ? orderCutoff : "无数据"
);
}
private String safeQueryDate(String sql) {
try {
return jdbcTemplate.queryForObject(sql, String.class);
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,29 @@
package com.chuyishidai.datahub.controller;
import com.chuyishidai.datahub.job.InsightComputeJob;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/insight")
@RequiredArgsConstructor
public class InsightController {
private final InsightComputeJob insightComputeJob;
/**
* 手动触发洞察计算任务 (测试用)
*/
@RequestMapping(value = "/compute-all", method = {org.springframework.web.bind.annotation.RequestMethod.GET, org.springframework.web.bind.annotation.RequestMethod.POST})
public Map<String, Object> computeAllInsights() {
try {
insightComputeJob.triggerInsightComputations();
return Map.of("status", "success", "message", "全域洞察计算任务已触发并完成");
} catch (Exception e) {
return Map.of("status", "error", "message", e.getMessage());
}
}
}

View File

@@ -0,0 +1,76 @@
package com.chuyishidai.datahub.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/product")
@RequiredArgsConstructor
public class ProductController {
private final JdbcTemplate jdbcTemplate;
/**
* 智能订货预测单 (基于最近14天流速, 按 outer_item_id 聚合)
*/
@GetMapping("/advice")
public List<Map<String, Object>> getStockAdvice() {
String sql = """
SELECT
outer_item_id, MAX(item_name) AS item_name,
SUM(sales_qty) AS sum_14d,
SUM(sales_qty) / 14.0 AS avg_daily_speed,
CEILING(SUM(sales_qty) / 14.0 * 3.0) AS advice_stock_qty
FROM adm_item_sales_trend
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 14 DAY)
AND outer_item_id IS NOT NULL AND outer_item_id != ''
GROUP BY outer_item_id
HAVING SUM(sales_qty) / 14.0 > 0
ORDER BY avg_daily_speed DESC
""";
return jdbcTemplate.queryForList(sql);
}
/**
* 购物篮分析排行榜 (关联销售)
*/
@GetMapping("/basket")
public List<Map<String, Object>> getBasketRules() {
String sql = """
SELECT
item_name_a, item_name_b,
pair_order_count, item_a_order_count,
confidence
FROM adm_item_basket
ORDER BY confidence DESC, pair_order_count DESC
LIMIT 20
""";
return jdbcTemplate.queryForList(sql);
}
/**
* 商品30天复购率与波士顿矩阵打标
*/
@GetMapping("/repurchase")
public List<Map<String, Object>> getRepurchaseRanking() {
String sql = """
SELECT
item_name, outer_item_id,
stat_date,
purchaser_count_30d,
repurchaser_count_30d,
repurchase_rate_30d,
matrix_tag
FROM adm_item_repurchase
WHERE stat_date = (SELECT MAX(stat_date) FROM adm_item_repurchase)
ORDER BY repurchase_rate_30d DESC, purchaser_count_30d DESC
""";
return jdbcTemplate.queryForList(sql);
}
}

View File

@@ -0,0 +1,202 @@
package com.chuyishidai.datahub.controller;
import com.chuyishidai.datahub.service.sync.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Map;
/**
* 同步任务手动触发控制器(开发/测试用)
*/
@Slf4j
@RestController
@RequestMapping("/api/sync")
@RequiredArgsConstructor
public class SyncController {
private final TradeSyncService tradeSyncService;
private final ItemSyncService itemSyncService;
private final CustomerSyncService customerSyncService;
private final StoreSyncService storeSyncService;
private final RefundSyncService refundSyncService;
private final InventorySyncService inventorySyncService;
@Value("${sync.trade.page-size:100}")
private int pageSize;
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 手动触发订单同步
* GET /api/sync/trade?startTime=2026-03-16 00:00:00&endTime=2026-03-17 00:00:00
*/
@GetMapping("/trade")
public Map<String, Object> syncTrade(
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime) {
LocalDateTime start;
LocalDateTime end;
if (startTime != null && endTime != null) {
start = LocalDateTime.parse(startTime, FMT);
end = LocalDateTime.parse(endTime, FMT);
} else {
end = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0).withNano(0);
start = end.minusDays(1);
}
log.info("[SyncController] 手动触发订单同步: {} ~ {}", start.format(FMT), end.format(FMT));
try {
long startTs = System.currentTimeMillis();
int count = tradeSyncService.syncTradesByUpdateTime(start, end, pageSize);
long elapsed = System.currentTimeMillis() - startTs;
return Map.of(
"status", "success",
"synced_count", count,
"time_window", start.format(FMT) + " ~ " + end.format(FMT),
"elapsed_ms", elapsed
);
} catch (Exception e) {
return errorResponse(e, "trade");
}
}
/**
* 手动触发商品同步(全量)
* GET /api/sync/item
*/
@GetMapping("/item")
public Map<String, Object> syncItem() {
log.info("[SyncController] 手动触发商品同步");
try {
long startTs = System.currentTimeMillis();
int count = itemSyncService.syncAllItems();
long elapsed = System.currentTimeMillis() - startTs;
return Map.of(
"status", "success",
"synced_count", count,
"elapsed_ms", elapsed
);
} catch (Exception e) {
return errorResponse(e, "item");
}
}
/**
* 手动触发客户同步(从订单表聚合客户信息)
* GET /api/sync/customer
*/
@GetMapping("/customer")
public Map<String, Object> syncCustomer() {
log.info("[SyncController] 手动触发客户同步(订单驱动模式)");
try {
long startTs = System.currentTimeMillis();
int count = customerSyncService.syncCustomersFromOrders();
long elapsed = System.currentTimeMillis() - startTs;
return Map.of(
"status", "success",
"synced_count", count,
"elapsed_ms", elapsed
);
} catch (Exception e) {
return errorResponse(e, "customer");
}
}
// ==================== Phase 3 ====================
/**
* 手动触发门店/仓库同步
* GET /api/sync/store
*/
@GetMapping("/store")
public Map<String, Object> syncStore() {
log.info("[SyncController] 手动触发门店/仓库同步");
try {
long startTs = System.currentTimeMillis();
int count = storeSyncService.syncStores();
long elapsed = System.currentTimeMillis() - startTs;
return Map.of("status", "success", "synced_count", count, "elapsed_ms", elapsed);
} catch (Exception e) {
return errorResponse(e, "store");
}
}
/**
* 手动触发退款同步
* GET /api/sync/refund?startTime=2026-03-18 00:00:00&endTime=2026-03-19 00:00:00
*/
@GetMapping("/refund")
public Map<String, Object> syncRefund(
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime) {
LocalDateTime start;
LocalDateTime end;
if (startTime != null && endTime != null) {
start = LocalDateTime.parse(startTime, FMT);
end = LocalDateTime.parse(endTime, FMT);
} else {
end = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0).withNano(0);
start = end.minusDays(1);
}
log.info("[SyncController] 手动触发退款同步: {} ~ {}", start.format(FMT), end.format(FMT));
try {
long startTs = System.currentTimeMillis();
int count = refundSyncService.syncRefundsByTime(start, end);
long elapsed = System.currentTimeMillis() - startTs;
return Map.of(
"status", "success",
"synced_count", count,
"time_window", start.format(FMT) + " ~ " + end.format(FMT),
"elapsed_ms", elapsed
);
} catch (Exception e) {
return errorResponse(e, "refund");
}
}
/**
* 手动触发库存快照
* GET /api/sync/inventory
*/
@GetMapping("/inventory")
public Map<String, Object> syncInventory() {
log.info("[SyncController] 手动触发库存快照");
try {
long startTs = System.currentTimeMillis();
int count = inventorySyncService.syncInventorySnapshot();
long elapsed = System.currentTimeMillis() - startTs;
return Map.of("status", "success", "synced_count", count, "elapsed_ms", elapsed);
} catch (Exception e) {
return errorResponse(e, "inventory");
}
}
private Map<String, Object> errorResponse(Exception e, String module) {
log.error("[SyncController] {} 同步异常", module, e);
Throwable root = e;
while (root.getCause() != null) root = root.getCause();
return Map.of(
"status", "error",
"module", module,
"error_type", root.getClass().getSimpleName(),
"error_message", root.getMessage() != null ? root.getMessage() : "unknown"
);
}
}

View File

@@ -0,0 +1,74 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 统一客户维度宽表 Entity
* 对齐 DDL: dim_customer_info
*/
@Data
@TableName("dim_customer_info")
public class CustomerInfo {
@TableId(type = IdType.AUTO)
private Long id;
/** 手机号(首选关联枢纽) */
private String mobile;
/** 有赞生态统一用户ID */
private String yzOpenId;
/** 微信生态UnionID */
private String wxUnionId;
/** 微信公众号/小程序OpenID */
private String wxOpenId;
/** 客户昵称快照 */
private String nickname;
/** 客户真实姓名 */
private String name;
/** 性别: 0-未知, 1-男, 2-女 */
private Integer gender;
/** 生日 */
private java.time.LocalDate birthday;
/** 首次注册/留资时间 */
private LocalDateTime registerTime;
/** 注册渠道 */
private String registerChannel;
/** 当前会员等级ID */
private Long memberLevelId;
/** 当前会员等级名称 */
private String memberLevelName;
/** 首单支付时间 */
private LocalDateTime firstPayTime;
/** 最近一次支付时间RFM-R */
private LocalDateTime lastPayTime;
/** 历史累计实付金额RFM-M */
private BigDecimal totalPayAmount;
/** 历史累计支付订单数RFM-F */
private Integer totalPayCount;
/** 客户标签集合 (JSON) */
private String customerTags;
/** 中台数据更新时间 (DB自动管理) */
@TableField(insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,39 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 客群洞察 - RFM 模型
*/
@Data
@TableName("adm_customer_rfm")
public class CustomerRfm {
@TableId(type = IdType.AUTO)
private Long id;
// 用户唯一标识
private String yzOpenId;
private String mobile;
// R: 最近消费时间、F: 90天消费频次、M: 90天消费金额
private LocalDateTime lastTradeTime;
private Integer tradeCount90d;
private BigDecimal tradeAmount90d;
// RFM 对应评分 (1-5分)
private Integer rScore;
private Integer fScore;
private Integer mScore;
// 系统划分的客群分类 (如: 重要价值客户)
private String rfmGroup;
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,29 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 客群特征标签表
*/
@Data
@TableName("adm_customer_tags")
public class CustomerTag {
@TableId(type = IdType.AUTO)
private Long id;
private String yzOpenId;
// 标签名称 (例如: 周末消费客, 囤货小能手)
private String tagName;
// 标签值或对应权重 (如有)
private String tagValue;
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,42 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 每日库存快照 Entity
* 对齐 DDL: dim_inventory_snapshot
*/
@Data
@TableName("dim_inventory_snapshot")
public class InventorySnapshot {
@TableId(type = IdType.AUTO)
private Long id;
/** 仓库编码 */
private String warehouseCode;
/** SKU编码 */
private String skuCode;
/** 实物库存 */
private BigDecimal stockNum;
/** 实物占用 */
private BigDecimal freezeNum;
/** 可用库存 (stock_num - freeze_num) */
private BigDecimal availableNum;
/** 快照日期 */
private LocalDate snapshotDate;
/** 中台同步时间 */
@TableField(insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,38 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 商品洞察 - 购物篮关联分析 (简化的Apriori)
*/
@Data
@TableName("adm_item_basket")
public class ItemBasket {
@TableId(type = IdType.AUTO)
private Long id;
// A -> B 的关联
private Long itemIdA;
private String itemNameA;
private Long itemIdB;
private String itemNameB;
// 共同出现的订单数
private Integer pairOrderCount;
// A 独立出现的订单数
private Integer itemAOrderCount;
// 置信度 (pairOrderCount / itemAOrderCount)
private BigDecimal confidence;
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,40 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 商品洞察 - 复购分析
*/
@Data
@TableName("adm_item_repurchase")
public class ItemRepurchase {
@TableId(type = IdType.AUTO)
private Long id;
private LocalDate statDate;
private Long itemId;
private Long skuId;
// 过去30天购买该商品的总人数
private Integer purchaserCount30d;
// 过去30天内购买该商品后再次回购的人数
private Integer repurchaserCount30d;
// 30天复购率
private BigDecimal repurchaseRate30d;
// 矩阵打标 (如: 核心引流款)
private String matrixTag;
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,34 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 商品洞察 - 单品日销售趋势
*/
@Data
@TableName("adm_item_sales_trend")
public class ItemSalesTrend {
@TableId(type = IdType.AUTO)
private Long id;
private LocalDate statDate;
private Long itemId;
private Long skuId;
private String itemName;
// 单日维度统计
private Integer salesQty;
private BigDecimal salesAmount;
private Integer orderCount;
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,69 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 商品与SKU维度表 Entity
* 对齐 DDL: dim_item_sku
* 价格单位与有赞API一致
*/
@Data
@TableName("dim_item_sku")
public class ItemSku {
@TableId(type = IdType.AUTO)
private Long id;
/** 有赞商品ID (NOT NULL) */
private Long itemId;
/** 有赞SKU ID (NOT NULL, 无SKU时为0) */
private Long skuId;
/** 商家外部商品编码 (关联核心) */
private String outerItemId;
/** 商家外部SKU编码 */
private String outerSkuId;
/** 商品条码 */
private String barcode;
/** 商品/SKU名称 (NOT NULL) */
private String title;
/** 商品别名/短链接标识 */
private String alias;
/** 叶子类目ID */
private Long categoryId;
/** 叶子类目名称 */
private String categoryName;
/** 商品分组ID列表 (逗号分隔) */
private String groupIds;
/** 当前售卖价(单位:分) */
private Long price;
/** 成本价(单位:分) */
private Long costPrice;
/** 是否上架: true-上架, false-下架 */
@TableField("is_listing")
private Boolean isListing;
/** 商品在有赞的创建时间 */
private LocalDateTime createdTime;
/** 商品最近更新时间 */
private LocalDateTime updateTime;
/** 中台数据更新时间 (DB自动管理) */
@TableField(insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,55 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 门店与网点维表 Entity
* 对齐 DDL: dim_offline_store
*/
@Data
@TableName("dim_offline_store")
public class OfflineStore {
@TableId(type = IdType.AUTO)
private Long id;
/** 有赞网点/门店ID */
private Long offlineId;
/** 组织店铺ID */
private String shopOrgId;
/** 门店名称 */
private String name;
/** 节点类型: STORE-门店, WAREHOUSE-仓库 */
private String type;
/** 省份 */
private String province;
/** 城市 */
private String city;
/** 区县 */
private String district;
/** 详细地址 */
private String address;
/** 营业状态: 1-营业, 0-关店 */
private Integer status;
/** 仓库编码 (库存API必需) */
private String warehouseCode;
/** 库存模式: 1-独立, 2-共享总部, 3-共享门店仓, 4-进出存 */
private Integer stockMode;
/** 中台数据更新时间 */
@TableField(insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,102 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 交易明细事实表 Entity
* 对齐 DDL: dwd_trade_order_detail
*/
@Data
@TableName("dwd_trade_order_detail")
public class TradeOrderDetail {
@TableId(type = IdType.AUTO)
private Long id;
/** 有赞主订单号 */
private String tid;
/** 有赞子订单号/明细ID */
private String oid;
/** 买家手机号 */
private String buyerPhone;
/** 有赞用户开放ID */
private String yzOpenId;
/** 粉丝ID */
private Long fansId;
/** 店铺组织ID */
private String shopOrgId;
/** 网点/门店ID */
private Long offlineId;
/** 商品ID (NOT NULL, 默认0) */
private Long itemId = 0L;
/** SKU ID (NOT NULL, 默认0) */
private Long skuId = 0L;
/** 商家外部商品编码 */
private String outerItemId;
/** 商家外部SKU编码 */
private String outerSkuId;
/** 商品名称快照 */
private String title;
/** 单商品原价 */
private BigDecimal price;
/** 购买数量 */
private Integer num;
/** 原价小计 */
private BigDecimal totalFee;
/** 折扣后价格 */
private BigDecimal discountPrice;
/** 实际支付摊销金额 */
private BigDecimal payment;
/** 订单状态 */
private String status;
/** 退款状态 */
private Integer refundState;
/** 订单来源平台 */
private String sourcePlatform;
/** 是否零售门店订单 */
@TableField("is_retail_order")
private Boolean isRetailOrder;
/** 订单类型 */
private Integer orderType;
/** 支付时间 */
private LocalDateTime payTime;
/** 订单创建时间 */
private LocalDateTime createdTime;
/** 订单最后更新时间 */
private LocalDateTime updateTime;
/** 订单完成时间 */
private LocalDateTime successTime;
/** 中台数据更新时间 (由 DB DEFAULT CURRENT_TIMESTAMP 自动管理) */
@TableField(insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,65 @@
package com.chuyishidai.datahub.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 售后与退款明细 Entity
* 对齐 DDL: dwd_trade_refund_detail
*/
@Data
@TableName("dwd_trade_refund_detail")
public class TradeRefundDetail {
@TableId(type = IdType.AUTO)
private Long id;
/** 退款单号 */
private String refundId;
/** 关联的主订单号 */
private String tid;
/** 关联的子订单明细ID */
private String oid;
/** 发起退款的门店ID */
private Long offlineId;
/** 退款类型: 1-仅退款, 2-退货退款 */
private Integer refundType;
/** 实际退款金额 */
private BigDecimal refundFee;
/** 退款原因 */
private String reason;
/** 退款状态 */
private String status;
/** 退款申请时间 */
private LocalDateTime createdTime;
/** 退款成功时间 */
private LocalDateTime successTime;
/** 退款诉求: 1-仅退款, 2-退货退款, 3-换货 */
private Integer refundDemand;
/** 退款阶段: 1-售中, 2-售后 */
private Integer refundPhase;
/** 买家 yz_open_id */
private String yzOpenId;
/** 最后修改时间 */
private LocalDateTime modifiedTime;
/** 中台数据更新时间 */
@TableField(insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime etlUpdateTime;
}

View File

@@ -0,0 +1,44 @@
package com.chuyishidai.datahub.job;
import com.chuyishidai.datahub.service.insight.CustomerInsightService;
import com.chuyishidai.datahub.service.insight.InventoryInsightService;
import com.chuyishidai.datahub.service.insight.ProductInsightService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class InsightComputeJob {
private final CustomerInsightService customerInsightService;
private final ProductInsightService productInsightService;
private final InventoryInsightService inventoryInsightService;
// 每天凌晨 4:00 执行(在基础数据同步完成后执行数据市集的数据加工)
@Scheduled(cron = "${sync.insight.cron:0 0 4 * * ?}")
public void triggerInsightComputations() {
log.info("[InsightComputeJob] --- 每日全域洞察聚合计算开始 ---");
try {
// 步骤1商品洞察
productInsightService.computeDailySalesTrend();
productInsightService.computeBasketRules();
productInsightService.computeRepurchaseRates();
// 步骤2客户洞察
customerInsightService.computeRfm();
customerInsightService.computeUserTags();
// 步骤3库存诊断与订货建议
inventoryInsightService.computeStockAdvice();
} catch (Exception e) {
log.error("[InsightComputeJob] 核心聚合任务执行失败: {}", e.getMessage(), e);
}
log.info("[InsightComputeJob] --- 每日全域洞察聚合计算结束 ---");
}
}

View File

@@ -0,0 +1,60 @@
package com.chuyishidai.datahub.job;
import com.chuyishidai.datahub.service.sync.CustomerSyncService;
import com.chuyishidai.datahub.service.sync.ItemSyncService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
/**
* 商品 + 客户同步定时任务
* 商品同步: 每日凌晨 2:30
* 客户同步: 每日凌晨 2:45订单驱动模式从订单表聚合
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ItemCustomerSyncJob {
private final ItemSyncService itemSyncService;
private final CustomerSyncService customerSyncService;
/**
* 商品全量同步:每日凌晨 2:30
*/
@Scheduled(cron = "${sync.item.cron:0 30 2 * * ?}")
public void syncItems() {
log.info("========== [ItemCustomerSyncJob] 商品同步任务开始 ==========");
long startTs = System.currentTimeMillis();
try {
int count = itemSyncService.syncAllItems();
long elapsed = System.currentTimeMillis() - startTs;
log.info("========== [ItemCustomerSyncJob] 商品同步完成, 共{}条, 耗时{}秒 ==========",
count, Duration.ofMillis(elapsed).getSeconds());
} catch (Exception e) {
log.error("========== [ItemCustomerSyncJob] 商品同步异常 ==========", e);
}
}
/**
* 客户同步:每日凌晨 2:45从订单表聚合客户数据
*/
@Scheduled(cron = "${sync.customer.cron:0 45 2 * * ?}")
public void syncCustomers() {
log.info("========== [ItemCustomerSyncJob] 客户同步任务开始(订单驱动模式) ==========");
long startTs = System.currentTimeMillis();
try {
int count = customerSyncService.syncCustomersFromOrders();
long elapsed = System.currentTimeMillis() - startTs;
log.info("========== [ItemCustomerSyncJob] 客户同步完成, 共{}条, 耗时{}秒 ==========",
count, Duration.ofMillis(elapsed).getSeconds());
} catch (Exception e) {
log.error("========== [ItemCustomerSyncJob] 客户同步异常 ==========", e);
}
}
}

View File

@@ -0,0 +1,160 @@
package com.chuyishidai.datahub.job;
import com.chuyishidai.datahub.service.sync.InventorySyncService;
import com.chuyishidai.datahub.service.sync.RefundSyncService;
import com.chuyishidai.datahub.service.sync.StoreSyncService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
/**
* Phase 3 定时同步任务
* 门店 03:00、退款 03:10(智能增量)、库存快照 03:20
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class Phase3SyncJob {
private final StoreSyncService storeSyncService;
private final RefundSyncService refundSyncService;
private final InventorySyncService inventorySyncService;
private final JdbcTemplate jdbcTemplate;
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 门店同步: 每日凌晨 3:00
*/
@Scheduled(cron = "${sync.store.cron:0 0 3 * * ?}")
public void syncStores() {
log.info("========== [Phase3SyncJob] 门店同步开始 ==========");
long startTs = System.currentTimeMillis();
try {
int count = storeSyncService.syncStores();
log.info("========== [Phase3SyncJob] 门店同步完成, 共{}条, 耗时{}秒 ==========",
count, Duration.ofMillis(System.currentTimeMillis() - startTs).getSeconds());
} catch (Exception e) {
log.error("========== [Phase3SyncJob] 门店同步异常 ==========", e);
}
}
/**
* 退款智能增量同步: 每日凌晨 3:10
*/
@Scheduled(cron = "${sync.refund.cron:0 10 3 * * ?}")
public void syncRefunds() {
log.info("========== [Phase3SyncJob] 退款智能增量同步开始 ==========");
RefundSyncResult result = smartRefundSync();
log.info("========== [Phase3SyncJob] 退款同步完成, 窗口 {} ~ {}, 共{}条, 耗时{}秒 ==========",
result.windowStart, result.windowEnd, result.count, result.elapsedSeconds);
}
/**
* 智能退款增量同步: 查数据库最后时间, 自动补全
* 供 Job 和 AdminOpsController 共用
*/
public RefundSyncResult smartRefundSync() {
return smartRefundSync(null);
}
/**
* 智能退款增量同步 (可选指定起始日期)
* @param fromDate 如果不为 null, 从该日期开始; 否则自动检测缺口
*/
public RefundSyncResult smartRefundSync(LocalDate fromDate) {
long startTs = System.currentTimeMillis();
LocalDateTime lastRefundTime = getLastRefundTime();
LocalDateTime now = LocalDateTime.now();
LocalDateTime windowStart;
if (fromDate != null) {
windowStart = fromDate.atStartOfDay();
log.info("[Phase3SyncJob] 用户指定起始日期: {}, 将从 {} 开始同步退款", fromDate, windowStart.format(FMT));
} else if (lastRefundTime == null) {
windowStart = LocalDate.now().withMonth(1).withDayOfMonth(1).atStartOfDay();
log.info("[Phase3SyncJob] 数据库无退款记录, 将从 {} 开始全量补全", windowStart.format(FMT));
} else {
// 从最后记录时间往前退30分钟作为重叠窗口
windowStart = lastRefundTime.minusMinutes(30);
log.info("[Phase3SyncJob] 数据库最新退款时间: {}, 将从 {} 开始增量补全",
lastRefundTime.format(FMT), windowStart.format(FMT));
}
LocalDateTime windowEnd = LocalDateTime.of(now.toLocalDate(), LocalTime.MIDNIGHT);
if (windowEnd.isBefore(windowStart) || windowEnd.isEqual(windowStart)) {
windowEnd = now;
}
int totalCount = 0;
long daysBetween = java.time.temporal.ChronoUnit.DAYS.between(
windowStart.toLocalDate(), windowEnd.toLocalDate());
if (daysBetween <= 1) {
totalCount = refundSyncService.syncRefundsByTime(windowStart, windowEnd);
} else {
log.info("[Phase3SyncJob] 检测到 {} 天退款数据缺口, 将按天切片补全", daysBetween);
LocalDate startDate = windowStart.toLocalDate();
LocalDate endDate = windowEnd.toLocalDate();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
LocalDateTime dayStart = date.equals(startDate) ? windowStart : date.atStartOfDay();
LocalDateTime dayEnd = date.equals(endDate) ? windowEnd : date.plusDays(1).atStartOfDay();
if (!dayEnd.isAfter(dayStart)) continue;
log.info("[Phase3SyncJob] 正在补全 {} 的退款单...", date);
try {
int count = refundSyncService.syncRefundsByTime(dayStart, dayEnd);
totalCount += count;
if (!date.equals(endDate)) Thread.sleep(300);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("[Phase3SyncJob] 补全 {} 退款异常: {}", date, e.getMessage());
}
}
}
long elapsed = Duration.ofMillis(System.currentTimeMillis() - startTs).getSeconds();
return new RefundSyncResult(windowStart.format(FMT), windowEnd.format(FMT), totalCount, elapsed);
}
private LocalDateTime getLastRefundTime() {
try {
return jdbcTemplate.queryForObject(
"SELECT MAX(created_time) FROM dwd_trade_refund_detail",
LocalDateTime.class);
} catch (Exception e) {
log.warn("[Phase3SyncJob] 查询最新退款时间异常: {}", e.getMessage());
return null;
}
}
/**
* 库存快照: 每日凌晨 3:20
*/
@Scheduled(cron = "${sync.inventory.cron:0 20 3 * * ?}")
public void syncInventory() {
log.info("========== [Phase3SyncJob] 库存快照开始 ==========");
long startTs = System.currentTimeMillis();
try {
int count = inventorySyncService.syncInventorySnapshot();
log.info("========== [Phase3SyncJob] 库存快照完成, 共{}条, 耗时{}秒 ==========",
count, Duration.ofMillis(System.currentTimeMillis() - startTs).getSeconds());
} catch (Exception e) {
log.error("========== [Phase3SyncJob] 库存快照异常 ==========", e);
}
}
public record RefundSyncResult(String windowStart, String windowEnd, int count, long elapsedSeconds) {}
}

View File

@@ -0,0 +1,150 @@
package com.chuyishidai.datahub.job;
import com.chuyishidai.datahub.service.sync.TradeSyncService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.Duration;
import java.time.format.DateTimeFormatter;
/**
* 订单同步定时任务 — 智能增量模式
*
* 自动检测数据库中最后一条订单的 update_time,
* 从该时间点开始补全到今天, 按天切片拉取.
* 如果从未同步过, 则从今年1月1日开始.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class TradeSyncJob {
private final TradeSyncService tradeSyncService;
private final JdbcTemplate jdbcTemplate;
@Value("${sync.trade.page-size:100}")
private int pageSize;
@Value("${sync.trade.overlap-minutes:30}")
private int overlapMinutes;
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* T+1 定时任务:每日凌晨 2:00 执行 (智能增量)
*/
@Scheduled(cron = "${sync.trade.cron:0 0 2 * * ?}")
public void syncYesterdayTrades() {
log.info("========== [TradeSyncJob] 订单智能增量同步任务开始 ==========");
SyncResult result = smartSync();
log.info("========== [TradeSyncJob] 同步完成, 窗口 {} ~ {}, 共{}条明细, 耗时{}秒 ==========",
result.windowStart, result.windowEnd, result.count, result.elapsedSeconds);
}
/**
* 智能增量同步: 查数据库最后时间, 自动补全缺失天数
* 供 Job 和 AdminOpsController 共用
*/
public SyncResult smartSync() {
return smartSync(null);
}
/**
* 智能增量同步 (可选指定起始日期)
* @param fromDate 如果不为 null, 从该日期开始同步; 否则自动检测缺口
*/
public SyncResult smartSync(LocalDate fromDate) {
long startTs = System.currentTimeMillis();
// 1. 查数据库中最新的订单时间
LocalDateTime lastSyncTime = getLastOrderTime();
LocalDateTime now = LocalDateTime.now();
// 2. 确定补全起点
LocalDateTime windowStart;
if (fromDate != null) {
// 用户指定了起始日期 → 直接从该日期 00:00 开始
windowStart = fromDate.atStartOfDay();
log.info("[TradeSyncJob] 用户指定起始日期: {}, 将从 {} 开始同步", fromDate, windowStart.format(FMT));
} else if (lastSyncTime == null) {
// 从未同步过 → 从今年1月1日开始
windowStart = LocalDate.now().withMonth(1).withDayOfMonth(1).atStartOfDay();
log.info("[TradeSyncJob] 数据库无订单记录, 将从 {} 开始全量补全", windowStart.format(FMT));
} else {
// 从最后同步时间减去重叠时间开始
windowStart = lastSyncTime.minusMinutes(overlapMinutes);
log.info("[TradeSyncJob] 数据库最新订单时间: {}, 将从 {} 开始增量补全",
lastSyncTime.format(FMT), windowStart.format(FMT));
}
LocalDateTime windowEnd = LocalDateTime.of(now.toLocalDate(), LocalTime.MIDNIGHT);
// 如果当前时间已过午夜, windowEnd 就是今天 00:00
// 如果 windowStart 已经是昨天以后, 直接同步到 now
if (windowEnd.isBefore(windowStart) || windowEnd.isEqual(windowStart)) {
windowEnd = now;
}
// 3. 按天切片拉取 (单窗口最多30天不做切片, 超过则按天)
int totalCount = 0;
long daysBetween = java.time.temporal.ChronoUnit.DAYS.between(
windowStart.toLocalDate(), windowEnd.toLocalDate());
if (daysBetween <= 1) {
// 1天以内, 直接拉取
totalCount = tradeSyncService.syncTradesByUpdateTime(windowStart, windowEnd, pageSize);
} else {
// 多天, 按天切片
log.info("[TradeSyncJob] 检测到 {} 天数据缺口, 将按天切片补全", daysBetween);
LocalDate startDate = windowStart.toLocalDate();
LocalDate endDate = windowEnd.toLocalDate();
for (LocalDate date = startDate; !date.isAfter(endDate); date = date.plusDays(1)) {
LocalDateTime dayStart = date.equals(startDate) ? windowStart : date.atStartOfDay();
LocalDateTime dayEnd = date.equals(endDate) ? windowEnd : date.plusDays(1).atStartOfDay();
if (!dayEnd.isAfter(dayStart)) continue;
log.info("[TradeSyncJob] 正在补全 {} 的订单...", date);
try {
int count = tradeSyncService.syncTradesByUpdateTime(dayStart, dayEnd, pageSize);
totalCount += count;
// 礼貌限速,避免 API 被封
if (!date.equals(endDate)) Thread.sleep(300);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("[TradeSyncJob] 补全 {} 异常: {}", date, e.getMessage());
}
}
}
long elapsed = Duration.ofMillis(System.currentTimeMillis() - startTs).getSeconds();
return new SyncResult(windowStart.format(FMT), windowEnd.format(FMT), totalCount, elapsed);
}
/**
* 查询订单表中最新的 update_time
*/
private LocalDateTime getLastOrderTime() {
try {
return jdbcTemplate.queryForObject(
"SELECT MAX(update_time) FROM dwd_trade_order_detail",
LocalDateTime.class);
} catch (Exception e) {
log.warn("[TradeSyncJob] 查询最新订单时间异常: {}", e.getMessage());
return null;
}
}
/**
* 同步结果, 供 AdminOpsController 读取并返回给前端
*/
public record SyncResult(String windowStart, String windowEnd, int count, long elapsedSeconds) {}
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.CustomerInfo;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CustomerInfoMapper extends BaseMapper<CustomerInfo> {
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.CustomerRfm;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CustomerRfmMapper extends BaseMapper<CustomerRfm> {
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.CustomerTag;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CustomerTagMapper extends BaseMapper<CustomerTag> {
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.InventorySnapshot;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface InventorySnapshotMapper extends BaseMapper<InventorySnapshot> {
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.ItemBasket;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ItemBasketMapper extends BaseMapper<ItemBasket> {
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.ItemRepurchase;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ItemRepurchaseMapper extends BaseMapper<ItemRepurchase> {
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.ItemSalesTrend;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ItemSalesTrendMapper extends BaseMapper<ItemSalesTrend> {
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.ItemSku;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ItemSkuMapper extends BaseMapper<ItemSku> {
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.OfflineStore;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OfflineStoreMapper extends BaseMapper<OfflineStore> {
}

View File

@@ -0,0 +1,12 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.TradeOrderDetail;
import org.apache.ibatis.annotations.Mapper;
/**
* 交易明细 Mapper
*/
@Mapper
public interface TradeOrderDetailMapper extends BaseMapper<TradeOrderDetail> {
}

View File

@@ -0,0 +1,9 @@
package com.chuyishidai.datahub.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.chuyishidai.datahub.entity.TradeRefundDetail;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface TradeRefundDetailMapper extends BaseMapper<TradeRefundDetail> {
}

View File

@@ -0,0 +1,233 @@
package com.chuyishidai.datahub.service.insight;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 会员洞察引擎
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomerInsightService {
private final JdbcTemplate jdbcTemplate;
/**
* 计算并更新全量用户的 RFM 评分及分层 (近90天维度)
*/
@Transactional
public void computeRfm() {
log.info("[CustomerInsight] 开始计算会员 RFM 模型...");
// 先清除旧数据,确保全量覆盖
jdbcTemplate.update("DELETE FROM adm_customer_rfm");
// 基于近 90 天订单明细表聚合
String sql = """
INSERT INTO adm_customer_rfm (
yz_open_id, mobile, last_trade_time,
trade_count_90d, trade_amount_90d,
r_score, f_score, m_score, rfm_group
)
SELECT
yz_open_id,
MAX(buyer_phone) AS mobile,
MAX(pay_time) AS last_trade_time,
COUNT(DISTINCT tid) AS trade_count_90d,
SUM(payment) AS trade_amount_90d,
-- R 得分: 距今时间越短得分越高
CASE
WHEN DATEDIFF(CURDATE(), MAX(pay_time)) <= 7 THEN 5
WHEN DATEDIFF(CURDATE(), MAX(pay_time)) <= 15 THEN 4
WHEN DATEDIFF(CURDATE(), MAX(pay_time)) <= 30 THEN 3
WHEN DATEDIFF(CURDATE(), MAX(pay_time)) <= 60 THEN 2
ELSE 1
END AS r_score,
-- F 得分: 90天内频次
CASE
WHEN COUNT(DISTINCT tid) >= 10 THEN 5
WHEN COUNT(DISTINCT tid) >= 5 THEN 4
WHEN COUNT(DISTINCT tid) >= 3 THEN 3
WHEN COUNT(DISTINCT tid) >= 2 THEN 2
ELSE 1
END AS f_score,
-- M 得分: 90天内金额
CASE
WHEN SUM(payment) >= 500 THEN 5
WHEN SUM(payment) >= 200 THEN 4
WHEN SUM(payment) >= 100 THEN 3
WHEN SUM(payment) >= 50 THEN 2
ELSE 1
END AS m_score,
-- 客群分层
CASE
WHEN DATEDIFF(CURDATE(), MAX(pay_time)) <= 30 AND COUNT(DISTINCT tid) >= 3 THEN '重要价值客户'
WHEN DATEDIFF(CURDATE(), MAX(pay_time)) <= 30 THEN '一般发展客户'
WHEN DATEDIFF(CURDATE(), MAX(pay_time)) > 60 AND COUNT(DISTINCT tid) >= 3 THEN '重要挽留客户'
WHEN DATEDIFF(CURDATE(), MAX(pay_time)) > 60 THEN '沉睡客户'
ELSE '一般保持客户'
END AS rfm_group
FROM (
SELECT yz_open_id, buyer_phone, pay_time, tid, payment
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
AND yz_open_id IS NOT NULL AND yz_open_id != ''
) t
GROUP BY yz_open_id
ON DUPLICATE KEY UPDATE
mobile = VALUES(mobile),
last_trade_time = VALUES(last_trade_time),
trade_count_90d = VALUES(trade_count_90d),
trade_amount_90d = VALUES(trade_amount_90d),
r_score = VALUES(r_score),
f_score = VALUES(f_score),
m_score = VALUES(m_score),
rfm_group = VALUES(rfm_group)
""";
try {
int rows = jdbcTemplate.update(sql);
log.info("[CustomerInsight] RFM 计算完成, 影响/更新了 {} 次记录", rows);
} catch (Exception e) {
log.error("[CustomerInsight] RFM 计算异常", e);
}
}
/**
* 计算并更新会员特征标签
*/
@Transactional
public void computeUserTags() {
log.info("[CustomerInsight] 开始计算会员标签画像...");
// 先清除旧标签,确保全量覆盖
jdbcTemplate.update("DELETE FROM adm_customer_tags");
try {
// 标签1: 工作日常客 (根据过去90天内周一到周五消费超3次)
String weekdayTagSql = """
INSERT INTO adm_customer_tags (yz_open_id, tag_name, tag_value)
SELECT yz_open_id, '工作日常客' AS tag_name, CAST(COUNT(DISTINCT tid) AS CHAR) AS tag_value
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
AND DAYOFWEEK(pay_time) BETWEEN 2 AND 6
AND yz_open_id IS NOT NULL AND yz_open_id != ''
GROUP BY yz_open_id
HAVING COUNT(DISTINCT tid) >= 3
ON DUPLICATE KEY UPDATE tag_value = VALUES(tag_value)
""";
int wTags = jdbcTemplate.update(weekdayTagSql);
// 标签2: 高客单囤货客 (单笔订单实付满200元)
String highValueTagSql = """
INSERT INTO adm_customer_tags (yz_open_id, tag_name, tag_value)
SELECT yz_open_id, '高客单囤货客' AS tag_name, CAST(MAX(order_total) AS CHAR) AS tag_value
FROM (
SELECT yz_open_id, tid, SUM(payment) AS order_total
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
AND yz_open_id IS NOT NULL AND yz_open_id != ''
GROUP BY yz_open_id, tid
) t
GROUP BY yz_open_id
HAVING MAX(order_total) >= 200
ON DUPLICATE KEY UPDATE tag_value = VALUES(tag_value)
""";
int hTags = jdbcTemplate.update(highValueTagSql);
// 标签3: 周末活跃客 (过去90天内周六日消费≥3次)
String weekendTagSql = """
INSERT INTO adm_customer_tags (yz_open_id, tag_name, tag_value)
SELECT yz_open_id, '周末活跃客' AS tag_name, CAST(COUNT(DISTINCT tid) AS CHAR) AS tag_value
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
AND DAYOFWEEK(pay_time) IN (1, 7)
AND yz_open_id IS NOT NULL AND yz_open_id != ''
GROUP BY yz_open_id
HAVING COUNT(DISTINCT tid) >= 3
ON DUPLICATE KEY UPDATE tag_value = VALUES(tag_value)
""";
int weTags = jdbcTemplate.update(weekendTagSql);
// 标签4: 新客户 (首单在近30天内)
String newCustTagSql = """
INSERT INTO adm_customer_tags (yz_open_id, tag_name, tag_value)
SELECT yz_open_id, '新客户' AS tag_name, DATE_FORMAT(MIN(pay_time), '%Y-%m-%d') AS tag_value
FROM dwd_trade_order_detail
WHERE yz_open_id IS NOT NULL AND yz_open_id != ''
AND pay_time IS NOT NULL
GROUP BY yz_open_id
HAVING MIN(pay_time) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
ON DUPLICATE KEY UPDATE tag_value = VALUES(tag_value)
""";
int nTags = jdbcTemplate.update(newCustTagSql);
// 标签5: 沉睡客户 (最近一次消费距今超过60天)
String sleepTagSql = """
INSERT INTO adm_customer_tags (yz_open_id, tag_name, tag_value)
SELECT yz_open_id, '沉睡客户' AS tag_name,
CONCAT(DATEDIFF(CURDATE(), MAX(pay_time)), '天') AS tag_value
FROM dwd_trade_order_detail
WHERE yz_open_id IS NOT NULL AND yz_open_id != ''
AND pay_time IS NOT NULL
GROUP BY yz_open_id
HAVING MAX(pay_time) < DATE_SUB(CURDATE(), INTERVAL 60 DAY)
ON DUPLICATE KEY UPDATE tag_value = VALUES(tag_value)
""";
int sTags = jdbcTemplate.update(sleepTagSql);
// 标签6: 高频复购客 (90天内消费≥10次)
String freqTagSql = """
INSERT INTO adm_customer_tags (yz_open_id, tag_name, tag_value)
SELECT yz_open_id, '高频复购客' AS tag_name, CAST(COUNT(DISTINCT tid) AS CHAR) AS tag_value
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
AND yz_open_id IS NOT NULL AND yz_open_id != ''
GROUP BY yz_open_id
HAVING COUNT(DISTINCT tid) >= 10
ON DUPLICATE KEY UPDATE tag_value = VALUES(tag_value)
""";
int fTags = jdbcTemplate.update(freqTagSql);
// 标签7: 多品类客户 (购买过≥3种不同商品)
String multiTagSql = """
INSERT INTO adm_customer_tags (yz_open_id, tag_name, tag_value)
SELECT yz_open_id, '多品类客户' AS tag_name, CAST(COUNT(DISTINCT outer_item_id) AS CHAR) AS tag_value
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
AND yz_open_id IS NOT NULL AND yz_open_id != ''
AND outer_item_id IS NOT NULL AND outer_item_id != ''
GROUP BY yz_open_id
HAVING COUNT(DISTINCT outer_item_id) >= 3
ON DUPLICATE KEY UPDATE tag_value = VALUES(tag_value)
""";
int mTags = jdbcTemplate.update(multiTagSql);
// 标签8: 大额客户 (历史累计消费≥1000元)
String bigTagSql = """
INSERT INTO adm_customer_tags (yz_open_id, tag_name, tag_value)
SELECT yz_open_id, '大额客户' AS tag_name,
CAST(ROUND(SUM(payment), 2) AS CHAR) AS tag_value
FROM dwd_trade_order_detail
WHERE yz_open_id IS NOT NULL AND yz_open_id != ''
GROUP BY yz_open_id
HAVING SUM(payment) >= 1000
ON DUPLICATE KEY UPDATE tag_value = VALUES(tag_value)
""";
int bTags = jdbcTemplate.update(bigTagSql);
log.info("[CustomerInsight] 会员画像更新完成. 工作日常客:{}, 高客单囤货客:{}, 周末活跃客:{}, 新客户:{}, 沉睡客户:{}, 高频复购客:{}, 多品类客户:{}, 大额客户:{}",
wTags, hTags, weTags, nTags, sTags, fTags, mTags, bTags);
} catch (Exception e) {
log.error("[CustomerInsight] 会员打标异常", e);
}
}
}

View File

@@ -0,0 +1,63 @@
package com.chuyishidai.datahub.service.insight;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
/**
* 订货预测与库存洞察
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InventoryInsightService {
private final JdbcTemplate jdbcTemplate;
/**
* 生成并输出订货建议
*/
public void computeStockAdvice() {
log.info("[InventoryInsight] 开始分析过去14天流速生成订货单建议...");
// 基于过去 14 天的总销量来推算日均流速
String sql = """
SELECT
item_id, item_name, sku_id,
SUM(sales_qty) AS sum_14d,
SUM(sales_qty) / 14.0 AS avg_daily_speed
FROM adm_item_sales_trend
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 14 DAY)
GROUP BY item_id, item_name, sku_id
HAVING SUM(sales_qty) / 14.0 > 0
ORDER BY avg_daily_speed DESC
""";
try {
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql);
if (list.isEmpty()) {
log.info("[InventoryInsight] 暂无足够的销售趋势数据用于测算智能订货流速");
} else {
for (Map<String, Object> map : list) {
try {
String itemName = (String) map.get("item_name");
Number speed = (Number) map.get("avg_daily_speed");
// 包含周末系数与安全库存系数: 建议备货 = 日均流速 * 3 (安全备货天数)
double adviceQty = speed.doubleValue() * 3.0;
log.info("[订货单建议] 商品: {}, 过去14天日均流速: {}. 建议安全备货量(3天量): {}",
itemName != null ? itemName : map.get("item_id"),
String.format("%.2f", speed.doubleValue()),
Math.ceil(adviceQty));
} catch (Exception ignored) {}
}
}
} catch (Exception e) {
log.error("[InventoryInsight] 测算流速异常", e);
}
}
}

View File

@@ -0,0 +1,211 @@
package com.chuyishidai.datahub.service.insight;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 商品洞察引擎
*
* 重要:有赞连锁模式下同一商品在不同门店有不同 item_id
* 唯一能跨门店统一标识商品的是 outer_item_id (商家编码)。
* 所有聚合必须按 outer_item_id 做 GROUP BY。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductInsightService {
private final JdbcTemplate jdbcTemplate;
/**
* 计算单日商品销售趋势 (按 outer_item_id 聚合)
*/
@Transactional
public void computeDailySalesTrend() {
log.info("[ProductInsight] 开始归集单日商品销售趋势...");
String sql = """
INSERT INTO adm_item_sales_trend (
stat_date, item_id, sku_id, outer_item_id, item_name,
sales_qty, sales_amount, order_count
)
SELECT
DATE(pay_time) AS stat_date,
MAX(item_id) AS item_id,
0 AS sku_id,
outer_item_id,
MAX(title) AS item_name,
SUM(num) AS sales_qty,
SUM(payment) AS sales_amount,
COUNT(DISTINCT tid) AS order_count
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 1 DAY)
AND pay_time < CURDATE()
AND outer_item_id IS NOT NULL AND outer_item_id != ''
AND title NOT LIKE '%餐具%'
GROUP BY DATE(pay_time), outer_item_id
ON DUPLICATE KEY UPDATE
item_id = VALUES(item_id),
item_name = VALUES(item_name),
sales_qty = VALUES(sales_qty),
sales_amount = VALUES(sales_amount),
order_count = VALUES(order_count)
""";
try {
int rows = jdbcTemplate.update(sql);
log.info("[ProductInsight] 单日商品销售归集完成, 聚合 {} 条商品数据", rows);
} catch (Exception e) {
log.error("[ProductInsight] 商品销售归集异常", e);
}
}
/**
* 挖掘过去30天的购物篮关联规律 (按 outer_item_id 聚合)
*/
@Transactional
public void computeBasketRules() {
log.info("[ProductInsight] 开始挖掘商品连带关系与购物篮...");
// 先清除旧数据,确保全量覆盖
jdbcTemplate.update("DELETE FROM adm_item_basket");
String sql = """
INSERT INTO adm_item_basket (
item_id_a, outer_item_id_a, item_name_a,
item_id_b, outer_item_id_b, item_name_b,
pair_order_count, item_a_order_count, confidence
)
WITH
-- 过去30天订单明细(按 outer_item_id 去重到商品级)
RecentOrders AS (
SELECT DISTINCT tid, outer_item_id, MAX(item_id) AS item_id, MAX(title) AS title
FROM dwd_trade_order_detail
WHERE pay_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND outer_item_id IS NOT NULL AND outer_item_id != ''
AND title NOT LIKE '%餐具%'
GROUP BY tid, outer_item_id
),
-- 统计所有商品的单项总订单数
ItemTotals AS (
SELECT outer_item_id, COUNT(DISTINCT tid) AS total_orders
FROM RecentOrders
GROUP BY outer_item_id
),
-- 组合商品 A 和 B (A != B)
Pairs AS (
SELECT
r1.outer_item_id AS outer_item_id_a, MAX(r1.item_id) AS item_id_a, MAX(r1.title) AS item_name_a,
r2.outer_item_id AS outer_item_id_b, MAX(r2.item_id) AS item_id_b, MAX(r2.title) AS item_name_b,
COUNT(DISTINCT r1.tid) AS pair_order_count
FROM RecentOrders r1
JOIN RecentOrders r2 ON r1.tid = r2.tid AND r1.outer_item_id != r2.outer_item_id
GROUP BY r1.outer_item_id, r2.outer_item_id
)
SELECT
p.item_id_a,
p.outer_item_id_a,
p.item_name_a,
p.item_id_b,
p.outer_item_id_b,
p.item_name_b,
p.pair_order_count,
t.total_orders AS item_a_order_count,
CAST((p.pair_order_count * 1.0 / t.total_orders) AS DECIMAL(5,4)) AS confidence
FROM Pairs p
JOIN ItemTotals t ON p.outer_item_id_a = t.outer_item_id
-- 只保留同现次数 >= 3次 且置信度 >= 5% 的有意义规律
WHERE p.pair_order_count >= 3 AND (p.pair_order_count * 1.0 / t.total_orders) >= 0.05
ON DUPLICATE KEY UPDATE
item_id_a = VALUES(item_id_a),
item_name_a = VALUES(item_name_a),
item_id_b = VALUES(item_id_b),
item_name_b = VALUES(item_name_b),
pair_order_count = VALUES(pair_order_count),
item_a_order_count = VALUES(item_a_order_count),
confidence = VALUES(confidence)
""";
try {
int rows = jdbcTemplate.update(sql);
log.info("[ProductInsight] 购物篮挖掘完成, 更新 {} 条有效关联规则", rows);
} catch (Exception e) {
log.error("[ProductInsight] 购物篮计算异常", e);
}
}
/**
* 计算近30天商品复购率及波士顿矩阵打标 (按 outer_item_id 聚合)
*/
@Transactional
public void computeRepurchaseRates() {
log.info("[ProductInsight] 开始测算单品30天复购率...");
// 先清除旧数据,确保全量覆盖
jdbcTemplate.update("DELETE FROM adm_item_repurchase");
String sql = """
INSERT INTO adm_item_repurchase (
stat_date, item_id, sku_id, outer_item_id, item_name,
purchaser_count_30d, repurchaser_count_30d,
repurchase_rate_30d, matrix_tag
)
WITH
UserItemOrders AS (
SELECT
o.yz_open_id, o.outer_item_id,
COUNT(DISTINCT o.tid) AS buy_freq
FROM dwd_trade_order_detail o
WHERE o.pay_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND o.yz_open_id IS NOT NULL AND o.yz_open_id != ''
AND o.outer_item_id IS NOT NULL AND o.outer_item_id != ''
AND o.title NOT LIKE '%餐具%'
GROUP BY o.yz_open_id, o.outer_item_id
),
Aggregated AS (
SELECT
outer_item_id,
COUNT(yz_open_id) AS purchaser_count_30d,
SUM(CASE WHEN buy_freq >= 2 THEN 1 ELSE 0 END) AS repurchaser_count_30d
FROM UserItemOrders
GROUP BY outer_item_id
)
SELECT
CURDATE() AS stat_date,
-- 取一个代表性 item_id
(SELECT MAX(d.item_id) FROM dwd_trade_order_detail d WHERE d.outer_item_id = a.outer_item_id) AS item_id,
0 AS sku_id,
a.outer_item_id,
(SELECT MAX(d.title) FROM dwd_trade_order_detail d WHERE d.outer_item_id = a.outer_item_id AND d.title IS NOT NULL) AS item_name,
a.purchaser_count_30d,
a.repurchaser_count_30d,
CAST(a.repurchaser_count_30d * 1.0 / a.purchaser_count_30d AS DECIMAL(5,4)) AS repurchase_rate_30d,
CASE
WHEN a.purchaser_count_30d >= 20 AND (a.repurchaser_count_30d * 1.0 / a.purchaser_count_30d) >= 0.2 THEN '核心引流爆款'
WHEN a.purchaser_count_30d >= 20 AND (a.repurchaser_count_30d * 1.0 / a.purchaser_count_30d) < 0.05 THEN '体验差需淘汰'
WHEN a.purchaser_count_30d < 10 AND (a.repurchaser_count_30d * 1.0 / a.purchaser_count_30d) >= 0.3 THEN '小众潜力款'
ELSE '平庸款'
END AS matrix_tag
FROM Aggregated a
ON DUPLICATE KEY UPDATE
item_id = VALUES(item_id),
item_name = VALUES(item_name),
purchaser_count_30d = VALUES(purchaser_count_30d),
repurchaser_count_30d = VALUES(repurchaser_count_30d),
repurchase_rate_30d = VALUES(repurchase_rate_30d),
matrix_tag = VALUES(matrix_tag)
""";
try {
int rows = jdbcTemplate.update(sql);
log.info("[ProductInsight] 单品复购率计算完成, 覆盖 {} 条商品", rows);
} catch (Exception e) {
log.error("[ProductInsight] 单品复购率计算异常", e);
}
}
}

View File

@@ -0,0 +1,233 @@
package com.chuyishidai.datahub.service.sync;
import com.chuyishidai.datahub.entity.CustomerInfo;
import com.chuyishidai.datahub.mapper.CustomerInfoMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* 客户同步服务 (Phase 2) — 订单驱动增量模式
*
* 策略:
* 1. 查 dim_customer_info 中最近更新时间 (etl_update_time 最大值)
* 2. 找出该时间之后有新订单的 yz_open_id (增量范围)
* 3. 仅对这些客户做全量重聚合 (保证金额/笔数准确)
* 4. UPSERT 到 dim_customer_info
*
* 首次执行或表为空时, 自动走全量模式.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomerSyncService {
private final CustomerInfoMapper customerInfoMapper;
private final JdbcTemplate jdbcTemplate;
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 增量聚合客户信息
* @return 新增+更新的客户数量
*/
public int syncCustomersFromOrders() {
log.info("[CustomerSyncService] 开始增量客户聚合");
// Step 1: 查客户表最近更新时间
LocalDateTime lastSyncTime = getLastCustomerSyncTime();
// Step 2: 确定增量范围内的客户 yz_open_id 集合
List<String> affectedOpenIds;
if (lastSyncTime == null) {
// 首次 → 全量
log.info("[CustomerSyncService] 客户表为空, 执行全量聚合");
affectedOpenIds = null; // null 表示全量
} else {
// 增量: 找出自上次客户聚合后, 订单表中有新增/更新的客户
// 关键: 用 etl_update_time (DB自动时间戳, 每次INSERT/UPDATE自动更新)
// 而不是 update_time (有赞业务时间戳) — 两者是不同时间体系!
LocalDateTime since = lastSyncTime.minusMinutes(30);
log.info("[CustomerSyncService] 增量模式: 查找 etl_update_time >= {} 的订单客户", since.format(FMT));
String findAffectedSql = """
SELECT DISTINCT yz_open_id
FROM dwd_trade_order_detail
WHERE yz_open_id IS NOT NULL AND yz_open_id != ''
AND etl_update_time >= ?
""";
affectedOpenIds = jdbcTemplate.queryForList(findAffectedSql, String.class, since);
if (affectedOpenIds.isEmpty()) {
log.info("[CustomerSyncService] 没有新增/变更订单, 无需聚合");
return 0;
}
log.info("[CustomerSyncService] 发现 {} 个客户有新订单, 开始增量聚合", affectedOpenIds.size());
}
// Step 3: 对受影响的客户做完整聚合 (保证金额/笔数一定准确)
String aggregateSql;
List<Map<String, Object>> rows;
if (affectedOpenIds == null) {
// 全量模式
aggregateSql = """
SELECT
yz_open_id,
MAX(buyer_phone) AS buyer_phone,
MIN(CASE WHEN status = 'TRADE_SUCCESS' THEN COALESCE(pay_time, created_time) END) AS first_pay_time,
MAX(CASE WHEN status = 'TRADE_SUCCESS' THEN COALESCE(pay_time, created_time) END) AS last_pay_time,
COALESCE(SUM(CASE WHEN status = 'TRADE_SUCCESS' THEN payment ELSE 0 END), 0) AS total_pay_amount,
COUNT(DISTINCT CASE WHEN status = 'TRADE_SUCCESS' THEN tid END) AS total_pay_count,
GROUP_CONCAT(DISTINCT source_platform SEPARATOR ',') AS source_platforms
FROM dwd_trade_order_detail
WHERE yz_open_id IS NOT NULL AND yz_open_id != ''
GROUP BY yz_open_id
""";
rows = jdbcTemplate.queryForList(aggregateSql);
} else {
// 增量模式: 只聚合受影响客户的全部订单 (不是只聚合新订单!)
// 用 IN 查询, 但分批处理防止 SQL 过长
rows = new ArrayList<>();
int batchSize = 500;
for (int i = 0; i < affectedOpenIds.size(); i += batchSize) {
List<String> batch = affectedOpenIds.subList(i, Math.min(i + batchSize, affectedOpenIds.size()));
String placeholders = String.join(",", Collections.nCopies(batch.size(), "?"));
String batchSql = String.format("""
SELECT
yz_open_id,
MAX(buyer_phone) AS buyer_phone,
MIN(CASE WHEN status = 'TRADE_SUCCESS' THEN COALESCE(pay_time, created_time) END) AS first_pay_time,
MAX(CASE WHEN status = 'TRADE_SUCCESS' THEN COALESCE(pay_time, created_time) END) AS last_pay_time,
COALESCE(SUM(CASE WHEN status = 'TRADE_SUCCESS' THEN payment ELSE 0 END), 0) AS total_pay_amount,
COUNT(DISTINCT CASE WHEN status = 'TRADE_SUCCESS' THEN tid END) AS total_pay_count,
GROUP_CONCAT(DISTINCT source_platform SEPARATOR ',') AS source_platforms
FROM dwd_trade_order_detail
WHERE yz_open_id IN (%s)
GROUP BY yz_open_id
""", placeholders);
rows.addAll(jdbcTemplate.queryForList(batchSql, batch.toArray()));
}
}
log.info("[CustomerSyncService] 聚合出 {} 个客户需要更新", rows.size());
// ====================================================================
// Step 4: Upsert — 新客户 INSERT / 老客户 UPDATE
//
// 遍历 Step 3 聚合出的每个客户(yz_open_id), 执行以下流程:
// ① 用 yz_open_id 去 dim_customer_info 表里查 → 判断是否老客户
// ② 如果查到了(existing != null) → 老客户, 把重新聚合的最新消费数据覆盖写入 → UPDATE
// ③ 如果没查到(existing == null) → 新客户, 创建新对象 → INSERT
//
// 注意: total_pay_amount / total_pay_count 是从订单表 SUM/COUNT 重算的,
// 不是在原值上 +=, 所以即使订单退款/取消也能保证准确.
// ====================================================================
int synced = 0;
for (Map<String, Object> row : rows) {
try {
String yzOpenId = (String) row.get("yz_open_id");
if (yzOpenId == null || yzOpenId.isBlank()) continue;
// ① 查客户表: 这个 yz_open_id 是否已存在?
LambdaQueryWrapper<CustomerInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CustomerInfo::getYzOpenId, yzOpenId);
CustomerInfo existing = customerInfoMapper.selectOne(wrapper);
// ② 老客户 → 复用已有对象; 新客户 → 创建新对象
CustomerInfo customer = (existing != null) ? existing : new CustomerInfo();
customer.setYzOpenId(yzOpenId);
// --- 以下字段用聚合结果覆盖 (不管新/老客户都一样写入) ---
// 手机号 (取订单中最新的 buyer_phone)
String phone = (String) row.get("buyer_phone");
if (phone != null && !phone.isBlank()) {
customer.setMobile(phone);
}
// 首次消费时间 (仅统计 TRADE_SUCCESS 订单)
Object firstPay = row.get("first_pay_time");
if (firstPay != null) {
customer.setFirstPayTime(toLocalDateTime(firstPay));
}
// 最近消费时间 (仅统计 TRADE_SUCCESS 订单)
Object lastPay = row.get("last_pay_time");
if (lastPay != null) {
customer.setLastPayTime(toLocalDateTime(lastPay));
}
// 累计消费金额 (从订单表 SUM 重算, 非累加)
Object totalAmount = row.get("total_pay_amount");
if (totalAmount != null) {
customer.setTotalPayAmount(new BigDecimal(totalAmount.toString()));
}
// 累计消费笔数 (从订单表 COUNT DISTINCT tid 重算)
Object totalCount = row.get("total_pay_count");
if (totalCount != null) {
customer.setTotalPayCount(((Number) totalCount).intValue());
}
// 来源渠道 (多渠道逗号分隔, 如 "meituan,eleme")
String platforms = (String) row.get("source_platforms");
if (platforms != null && !platforms.isBlank()) {
customer.setRegisterChannel(platforms);
}
// 注册时间 (新客户首次写入, 用首次消费时间代替)
if (customer.getRegisterTime() == null && customer.getFirstPayTime() != null) {
customer.setRegisterTime(customer.getFirstPayTime());
}
// ③ 执行持久化: 老客户 UPDATE / 新客户 INSERT
if (existing != null) {
// 老客户: 用重新聚合的数据覆盖更新
customerInfoMapper.updateById(customer);
} else {
// 新客户: 首次入库
customerInfoMapper.insert(customer);
}
synced++;
} catch (Exception e) {
log.warn("[CustomerSyncService] 处理客户 {} 失败: {}",
row.get("yz_open_id"), e.getMessage());
}
}
log.info("[CustomerSyncService] 客户聚合完成, 共更新 {} 条", synced);
return synced;
}
/**
* 查客户表最近的 etl_update_time
*/
private LocalDateTime getLastCustomerSyncTime() {
try {
return jdbcTemplate.queryForObject(
"SELECT MAX(etl_update_time) FROM dim_customer_info",
LocalDateTime.class);
} catch (Exception e) {
return null;
}
}
private LocalDateTime toLocalDateTime(Object obj) {
if (obj instanceof Timestamp ts) {
return ts.toLocalDateTime();
} else if (obj instanceof LocalDateTime ldt) {
return ldt;
}
return null;
}
}

View File

@@ -0,0 +1,173 @@
package com.chuyishidai.datahub.service.sync;
import com.chuyishidai.datahub.entity.InventorySnapshot;
import com.chuyishidai.datahub.mapper.InventorySnapshotMapper;
import com.chuyishidai.datahub.service.youzan.YouzanApiClient;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
/**
* 库存快照同步服务 (Phase 3)
*
* API: youzan.retail.open.query.warehousestock/1.0.0 (POST)
*
* 逻辑:
* 1. 从 dim_offline_store 取所有 warehouse_code
* 2. 从 dim_item_sku 取所有 SKU outer_sku_id
* 3. 分批查询 (每次最多 20 个 SKU × 每个仓库)
* 4. UPSERT 到 dim_inventory_snapshot (按 warehouse_code + sku_code + snapshot_date)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InventorySyncService {
private final YouzanApiClient apiClient;
private final InventorySnapshotMapper snapshotMapper;
private final JdbcTemplate jdbcTemplate;
private static final String API_NAME = "youzan.retail.open.offline.spu.query";
private static final String API_VERSION = "3.0.0";
/**
* 执行今日库存快照
*/
public int syncInventorySnapshot() {
LocalDate today = LocalDate.now();
log.info("[InventorySyncService] 开始采集库存快照, 日期: {}", today);
// Step 1: 获取所有仓库编码
List<String> warehouseCodes = jdbcTemplate.queryForList(
"SELECT warehouse_code FROM dim_offline_store WHERE warehouse_code IS NOT NULL",
String.class);
if (warehouseCodes.isEmpty()) {
log.warn("[InventorySyncService] 未找到仓库编码, 请先同步门店数据");
return 0;
}
log.info("[InventorySyncService] 共{}个仓库", warehouseCodes.size());
// Step 2: 逐仓库拉取商品库存
int totalSynced = 0;
for (String warehouseCode : warehouseCodes) {
int storeCount = syncWarehouseStock(warehouseCode, today);
totalSynced += storeCount;
log.info("[InventorySyncService] 仓库 {} 采集了 {} 条库存", warehouseCode, storeCount);
}
log.info("[InventorySyncService] 库存快照完成, 共{}条", totalSynced);
return totalSynced;
}
private int syncWarehouseStock(String warehouseCode, LocalDate snapshotDate) {
int synced = 0;
int pageNo = 1;
int pageSize = 50;
while (true) {
// API 文档: page_no * page_size 不能超过 3300
if (pageNo * pageSize > 3300) {
log.warn("[InventorySyncService] 仓库 {} 达到 API 极限深度(3300)", warehouseCode);
break;
}
Map<String, Object> params = new HashMap<>();
params.put("warehouse_code", warehouseCode);
params.put("page_no", pageNo);
params.put("page_size", pageSize);
try {
JsonNode data = apiClient.callApiPost(API_NAME, API_VERSION, params);
if (data == null || !data.has("offline_spus")) {
break;
}
JsonNode spus = data.get("offline_spus");
if (spus == null || !spus.isArray() || spus.isEmpty()) {
break;
}
for (JsonNode item : spus) {
InventorySnapshot snapshot = new InventorySnapshot();
snapshot.setWarehouseCode(warehouseCode);
// 优先使用 spu_no (对应商家编码 item_no/outer_item_id),若无则退化为 item_id
String skuCode = getText(item, "spu_no");
if (skuCode == null || skuCode.isBlank()) {
skuCode = getText(item, "item_id");
}
snapshot.setSkuCode(skuCode);
snapshot.setSnapshotDate(snapshotDate);
// offline.spu.query 仅返回 sell_stock_count不返回冻结数量
BigDecimal stockNum = parseBigDecimal(getText(item, "sell_stock_count"));
BigDecimal freezeNum = BigDecimal.ZERO;
snapshot.setStockNum(stockNum);
snapshot.setFreezeNum(freezeNum);
snapshot.setAvailableNum(stockNum); // 可用=总减去冻结(0)
upsertSnapshot(snapshot);
synced++;
}
// 判断是否还有下一页
JsonNode paginator = data.get("paginator");
if (paginator != null && paginator.has("total_count")) {
int totalCount = paginator.get("total_count").asInt(0);
if (pageNo * pageSize >= totalCount) {
break;
}
} else {
break;
}
pageNo++;
} catch (Exception e) {
log.warn("[InventorySyncService] 仓库{} 第{}页库存查询异常: {}", warehouseCode, pageNo, e.getMessage());
// 遇到错误(如门店编码不合法)跳出当前仓库,继续下一个
break;
}
}
return synced;
}
private void upsertSnapshot(InventorySnapshot snapshot) {
if (snapshot.getSkuCode() == null || snapshot.getWarehouseCode() == null) return;
LambdaQueryWrapper<InventorySnapshot> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(InventorySnapshot::getWarehouseCode, snapshot.getWarehouseCode())
.eq(InventorySnapshot::getSkuCode, snapshot.getSkuCode())
.eq(InventorySnapshot::getSnapshotDate, snapshot.getSnapshotDate());
InventorySnapshot existing = snapshotMapper.selectOne(wrapper);
if (existing != null) {
snapshot.setId(existing.getId());
snapshotMapper.updateById(snapshot);
} else {
snapshotMapper.insert(snapshot);
}
}
private String getText(JsonNode node, String field) {
return (node != null && node.has(field) && !node.get(field).isNull())
? node.get(field).asText() : null;
}
private BigDecimal parseBigDecimal(String str) {
if (str == null || str.isBlank()) return BigDecimal.ZERO;
try {
return new BigDecimal(str);
} catch (NumberFormatException e) {
return BigDecimal.ZERO;
}
}
}

View File

@@ -0,0 +1,194 @@
package com.chuyishidai.datahub.service.sync;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.chuyishidai.datahub.entity.ItemSku;
import com.chuyishidai.datahub.mapper.ItemSkuMapper;
import com.chuyishidai.datahub.service.youzan.YouzanApiClient;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* 商品同步服务 (Phase 2)
*
* API: youzan.item.search/3.0.0
*
* 官方文档要点:
* - price 单位为分 (Long),如 1000 = ¥10.00
* - page_size 最大 50
* - page_size * page_no <= 4000
* - 只能查"销售中"和"已售罄",无法查"仓库中"
* - show_sold_out=2 查全部(在售+售罄)
* - data.count 为总数
* - search 接口不返回 SKU 详情,需额外调 item.detail.get
*
* 策略:每日全量同步,基于 (item_id, sku_id) 唯一键 UPSERT
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ItemSyncService {
private final YouzanApiClient apiClient;
private final ItemSkuMapper itemSkuMapper;
private static final String SEARCH_API = "youzan.item.search";
private static final String SEARCH_VERSION = "3.0.0";
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 全量同步商品
* @return 同步的商品条数
*/
public int syncAllItems() {
log.info("[ItemSyncService] 开始全量同步商品");
int totalSynced = 0;
int pageNo = 1;
int pageSize = 50; // 官方文档: page_size 最大 50
while (true) {
// 官方文档: page_size * page_no <= 4000
if (pageNo * pageSize > 4000) {
log.warn("[ItemSyncService] 已达分页上限 (page_no*page_size>4000), 需用时间切片. 已同步{}条", totalSynced);
break;
}
Map<String, Object> params = new HashMap<>();
params.put("page_no", pageNo);
params.put("page_size", pageSize);
params.put("show_sold_out", 2); // 0-在售, 1-售罄, 2-全部
JsonNode data;
try {
data = apiClient.callApi(SEARCH_API, SEARCH_VERSION, params);
} catch (Exception e) {
log.error("[ItemSyncService] 第{}页API调用失败", pageNo, e);
break;
}
if (data == null) {
log.warn("[ItemSyncService] API 返回 data 为空, 同步结束");
break;
}
JsonNode items = data.get("items");
if (items == null || items.isEmpty()) {
log.info("[ItemSyncService] 第{}页无数据, 同步结束", pageNo);
break;
}
int pageSynced = 0;
for (JsonNode item : items) {
ItemSku entity = parseItem(item);
if (entity != null) {
upsertItemSku(entity);
pageSynced++;
}
}
totalSynced += pageSynced;
log.info("[ItemSyncService] 第{}页同步完成, 本页{}条", pageNo, pageSynced);
// 检查是否还有更多页
long totalCount = data.has("count") ? data.get("count").asLong(0) : 0;
if ((long) pageNo * pageSize >= totalCount) {
log.info("[ItemSyncService] 已拉取全部商品 (count={})", totalCount);
break;
}
pageNo++;
}
log.info("[ItemSyncService] 商品同步完成, 共{}条", totalSynced);
return totalSynced;
}
/**
* 解析一条商品 → ItemSku
* search 接口不返回 SKU 列表,每个商品按 item_id 粒度存储sku_id=0
*/
private ItemSku parseItem(JsonNode item) {
long itemId = item.has("item_id") ? item.get("item_id").asLong(0) : 0;
if (itemId == 0) return null;
ItemSku entity = new ItemSku();
entity.setItemId(itemId);
entity.setSkuId(0L); // search 接口不区分 SKU
entity.setTitle(getText(item, "title"));
entity.setAlias(getText(item, "alias"));
entity.setOuterItemId(getText(item, "item_no")); // 商家编码
entity.setBarcode(getText(item, "item_no"));
// 价格API 返回 Long 类型,单位为分
entity.setPrice(item.has("price") ? item.get("price").asLong(0) : 0L);
// 成本价:从 sku_extension_attributes 中取(如有)
long costPrice = 0L;
JsonNode skuExt = item.get("sku_extension_attributes");
if (skuExt != null && skuExt.isArray() && !skuExt.isEmpty()) {
JsonNode first = skuExt.get(0);
if (first.has("cost_price")) {
costPrice = first.get("cost_price").asLong(0);
}
}
entity.setCostPrice(costPrice);
// 分组 tag_ids → 逗号分隔
if (item.has("tag_ids") && item.get("tag_ids").isArray()) {
List<String> ids = new ArrayList<>();
for (JsonNode tag : item.get("tag_ids")) {
ids.add(tag.asText());
}
entity.setGroupIds(String.join(",", ids));
}
// 上下架状态search 接口只返回在售和已售罄,默认上架
entity.setIsListing(true);
// 时间
entity.setCreatedTime(parseDateTime(getText(item, "created_time")));
entity.setUpdateTime(parseDateTime(getText(item, "update_time")));
return entity;
}
/**
* UPSERT基于 (item_id, sku_id) 唯一键
*/
private void upsertItemSku(ItemSku itemSku) {
LambdaQueryWrapper<ItemSku> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ItemSku::getItemId, itemSku.getItemId())
.eq(ItemSku::getSkuId, itemSku.getSkuId());
ItemSku existing = itemSkuMapper.selectOne(wrapper);
if (existing != null) {
itemSku.setId(existing.getId());
itemSkuMapper.updateById(itemSku);
} else {
itemSkuMapper.insert(itemSku);
}
}
// ========== JSON 工具方法 ==========
private String getText(JsonNode node, String field) {
if (node == null || !node.has(field) || node.get(field).isNull()) return null;
String text = node.get(field).asText();
return text.isEmpty() ? null : text;
}
private LocalDateTime parseDateTime(String text) {
if (text == null || text.isBlank()) return null;
try {
return LocalDateTime.parse(text, FMT);
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,202 @@
package com.chuyishidai.datahub.service.sync;
import com.chuyishidai.datahub.entity.TradeRefundDetail;
import com.chuyishidai.datahub.mapper.TradeRefundDetailMapper;
import com.chuyishidai.datahub.service.youzan.YouzanApiClient;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* 退款/售后同步服务 (Phase 3)
*
* API: youzan.trade.refund.search/3.0.1 (POST)
*
* 关键注意:
* - 时间戳参数单位是秒10位不是毫秒
* - page_no ≤ 100, page_no * page_size ≤ 3000
* - 用 data.total 判断分页,不依赖 has_next
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RefundSyncService {
private final YouzanApiClient apiClient;
private final TradeRefundDetailMapper refundMapper;
private static final String API_NAME = "youzan.trade.refund.search";
private static final String API_VERSION = "3.0.1";
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final int PAGE_SIZE = 30; // 30 * 100 = 3000, 刚好不超限
/**
* 按时间窗口同步退款单
*/
public int syncRefundsByTime(LocalDateTime startTime, LocalDateTime endTime) {
log.info("[RefundSyncService] 开始同步退款单, 时间窗口: {} ~ {}", startTime.format(FMT), endTime.format(FMT));
// 转为秒级时间戳 (API 文档明确是秒!)
long startEpoch = startTime.atZone(ZoneId.systemDefault()).toEpochSecond();
long endEpoch = endTime.atZone(ZoneId.systemDefault()).toEpochSecond();
int totalSynced = 0;
int pageNo = 1;
while (true) {
if (pageNo > 100) {
log.warn("[RefundSyncService] 已达最大页码100, 需缩小时间窗口! 已同步{}条", totalSynced);
break;
}
Map<String, Object> params = new HashMap<>();
params.put("create_time_start", startEpoch);
params.put("create_time_end", endEpoch);
params.put("page_no", pageNo);
params.put("page_size", PAGE_SIZE);
JsonNode data;
try {
data = apiClient.callApiPost(API_NAME, API_VERSION, params);
} catch (Exception e) {
log.warn("[RefundSyncService] API调用异常: {}", e.getMessage());
break;
}
if (data == null) {
log.warn("[RefundSyncService] API 返回 data 为空");
break;
}
int total = data.has("total") ? data.get("total").asInt(0) : 0;
JsonNode refunds = data.get("refunds");
if (refunds == null || !refunds.isArray() || refunds.isEmpty()) {
log.info("[RefundSyncService] 第{}页无数据, 同步结束", pageNo);
break;
}
int pageSynced = 0;
boolean firstRecord = (pageNo == 1 && totalSynced == 0);
for (JsonNode refundNode : refunds) {
try {
// 调试: 将第一条退款单原始字段写入文件
if (firstRecord) {
try {
com.fasterxml.jackson.databind.ObjectMapper debugMapper = new com.fasterxml.jackson.databind.ObjectMapper();
String json = debugMapper.writerWithDefaultPrettyPrinter().writeValueAsString(refundNode);
java.nio.file.Files.writeString(java.nio.file.Path.of("refund_first_record.json"), json);
log.info("[RefundSyncService] DEBUG 第一条退款单已写入 refund_first_record.json");
} catch (Exception ex) {
log.warn("DEBUG写入失败: {}", ex.getMessage());
}
firstRecord = false;
}
TradeRefundDetail detail = parseRefund(refundNode);
upsertRefund(detail);
pageSynced++;
} catch (Exception e) {
log.warn("[RefundSyncService] 解析退款单异常: {}", e.getMessage());
}
}
totalSynced += pageSynced;
int totalPages = (total + PAGE_SIZE - 1) / PAGE_SIZE;
log.info("[RefundSyncService] 第{}/{}页同步完成, 本页{}条 (total={})",
pageNo, totalPages, pageSynced, total);
if (pageNo * PAGE_SIZE >= total) {
log.info("[RefundSyncService] 已拉取全部退款单 ({}/{})", pageNo * PAGE_SIZE, total);
break;
}
pageNo++;
}
log.info("[RefundSyncService] 同步完成, 共{}条退款单", totalSynced);
return totalSynced;
}
private TradeRefundDetail parseRefund(JsonNode node) {
TradeRefundDetail detail = new TradeRefundDetail();
detail.setRefundId(getText(node, "refund_id"));
detail.setTid(getText(node, "tid"));
detail.setStatus(getText(node, "status"));
detail.setYzOpenId(getText(node, "yz_open_id"));
// refund_fee 是字符串形式的金额
String feeStr = getText(node, "refund_fee");
if (feeStr != null) {
try {
detail.setRefundFee(new BigDecimal(feeStr));
} catch (NumberFormatException ignore) { }
}
// reason 是整数编码,存为原因描述
if (node.has("reason")) {
detail.setReason(String.valueOf(node.get("reason").asInt()));
}
// return_goods -> refundType: true=退货退款(2), false=仅退款(1)
if (node.has("return_goods")) {
detail.setRefundType(node.get("return_goods").asBoolean() ? 2 : 1);
}
// demand -> refundDemand
if (node.has("demand")) {
detail.setRefundDemand(node.get("demand").asInt());
}
// phase -> refundPhase
if (node.has("phase")) {
detail.setRefundPhase(node.get("phase").asInt());
}
// node_kdt_id -> offlineId
if (node.has("node_kdt_id") && !node.get("node_kdt_id").isNull()) {
detail.setOfflineId(node.get("node_kdt_id").asLong());
}
// 时间: "yyyy-MM-dd HH:mm:ss" 格式
detail.setCreatedTime(parseDateTime(getText(node, "created")));
detail.setModifiedTime(parseDateTime(getText(node, "modified")));
return detail;
}
private void upsertRefund(TradeRefundDetail detail) {
if (detail.getRefundId() == null) return;
LambdaQueryWrapper<TradeRefundDetail> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TradeRefundDetail::getRefundId, detail.getRefundId());
TradeRefundDetail existing = refundMapper.selectOne(wrapper);
if (existing != null) {
detail.setId(existing.getId());
refundMapper.updateById(detail);
} else {
refundMapper.insert(detail);
}
}
private String getText(JsonNode node, String field) {
return (node != null && node.has(field) && !node.get(field).isNull())
? node.get(field).asText() : null;
}
private LocalDateTime parseDateTime(String str) {
if (str == null || str.isBlank()) return null;
try {
return LocalDateTime.parse(str, FMT);
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,212 @@
package com.chuyishidai.datahub.service.sync;
import com.chuyishidai.datahub.entity.OfflineStore;
import com.chuyishidai.datahub.mapper.OfflineStoreMapper;
import com.chuyishidai.datahub.service.youzan.YouzanApiClient;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* 门店/仓库同步服务 (Phase 3)
*
* 调用两个有赞零售API
* 1. youzan.retail.open.warehouse.query/3.0.0 — 获取门店/仓库列表
* 2. youzan.retail.open.stock.mode.query/1.0.0 — 获取库存模式
*
* 均为 POST 请求
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StoreSyncService {
private final YouzanApiClient apiClient;
private final OfflineStoreMapper storeMapper;
private static final String WAREHOUSE_API = "youzan.retail.open.warehouse.query";
private static final String WAREHOUSE_VERSION = "3.0.0";
private static final String STOCK_MODE_API = "youzan.retail.open.stock.mode.query";
private static final String STOCK_MODE_VERSION = "1.0.0";
/**
* 全量同步门店和仓库信息
*/
public int syncStores() {
log.info("[StoreSyncService] 开始同步门店/仓库信息");
// Step 1: 拉取门店/仓库列表
List<OfflineStore> stores = fetchAllWarehouses();
log.info("[StoreSyncService] 从API获取到{}个门店/仓库", stores.size());
// Step 2: 拉取库存模式并填充
Map<Long, Integer> stockModeMap = fetchStockModes();
for (OfflineStore store : stores) {
if (store.getOfflineId() != null && stockModeMap.containsKey(store.getOfflineId())) {
store.setStockMode(stockModeMap.get(store.getOfflineId()));
}
}
// Step 3: UPSERT
int synced = 0;
for (OfflineStore store : stores) {
try {
LambdaQueryWrapper<OfflineStore> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OfflineStore::getOfflineId, store.getOfflineId());
OfflineStore existing = storeMapper.selectOne(wrapper);
if (existing != null) {
store.setId(existing.getId());
storeMapper.updateById(store);
} else {
storeMapper.insert(store);
}
synced++;
} catch (Exception e) {
log.warn("[StoreSyncService] 处理门店 {} 失败: {}", store.getOfflineId(), e.getMessage());
}
}
log.info("[StoreSyncService] 同步完成, 共{}条", synced);
return synced;
}
/**
* 分页拉取所有门店/仓库
*/
private List<OfflineStore> fetchAllWarehouses() {
List<OfflineStore> result = new ArrayList<>();
int pageNo = 1;
int pageSize = 20;
while (true) {
Map<String, Object> params = new HashMap<>();
params.put("page_no", pageNo);
params.put("page_size", pageSize);
JsonNode data;
try {
data = apiClient.callApiPost(WAREHOUSE_API, WAREHOUSE_VERSION, params);
} catch (Exception e) {
log.warn("[StoreSyncService] warehouse.query 调用异常: {}", e.getMessage());
// 写异常信息到debug文件
try {
java.nio.file.Files.writeString(java.nio.file.Path.of("store_debug.txt"),
"Exception: " + e.getClass().getName() + " - " + e.getMessage());
} catch (Exception ignored) {}
break;
}
// DEBUG: 写原始响应到文件
try {
com.fasterxml.jackson.databind.ObjectMapper dm = new com.fasterxml.jackson.databind.ObjectMapper();
String json = dm.writerWithDefaultPrettyPrinter().writeValueAsString(data);
java.nio.file.Files.writeString(java.nio.file.Path.of("store_debug.txt"),
"page_no=" + pageNo + ", params=" + params + "\ndata=\n" + json);
log.info("[StoreSyncService] DEBUG 原始响应已写入 store_debug.txt");
} catch (Exception ignored) {}
if (data == null) break;
// 实际字段: data.warehouses[] + data.paginator.total_count
JsonNode paginator = data.get("paginator");
int totalCount = (paginator != null && paginator.has("total_count"))
? paginator.get("total_count").asInt(0) : 0;
JsonNode warehouses = data.get("warehouses");
if (warehouses == null || !warehouses.isArray() || warehouses.isEmpty()) {
log.warn("[StoreSyncService] warehouse.query 返回无 warehouses");
break;
}
for (JsonNode item : warehouses) {
OfflineStore store = new OfflineStore();
// warehouse_id → offlineId (原 kdt_id 猜测错误)
store.setOfflineId(getLong(item, "warehouse_id"));
// name 即门店名
store.setName(getText(item, "name"));
store.setWarehouseCode(getText(item, "warehouse_code"));
store.setAddress(getText(item, "address"));
store.setProvince(getText(item, "province"));
store.setCity(getText(item, "city"));
store.setDistrict(getText(item, "area")); // area → district
// type: 1=仓库, 2=门店
if (item.has("type")) {
int wt = item.get("type").asInt();
store.setType(wt == 2 ? "STORE" : "WAREHOUSE");
}
// status: 0=营业, 1=关闭 (有赞的值与我们表相反,需转换)
if (item.has("status")) {
int apiStatus = item.get("status").asInt();
store.setStatus(apiStatus == 0 ? 1 : 0);
}
result.add(store);
}
if (pageNo * pageSize >= totalCount) break;
pageNo++;
}
return result;
}
/**
* 拉取所有门店的库存模式
*/
private Map<Long, Integer> fetchStockModes() {
Map<Long, Integer> modeMap = new HashMap<>();
int pageNo = 1;
int pageSize = 20;
while (true) {
Map<String, Object> params = new HashMap<>();
params.put("retail_source", "YOUZAN");
params.put("page_no", pageNo);
params.put("page_size", pageSize);
try {
JsonNode data = apiClient.callApiPost(STOCK_MODE_API, STOCK_MODE_VERSION, params);
if (data == null) break;
JsonNode list = data.get("open_stock_mode_search_d_t_o");
if (list == null || !list.isArray() || list.isEmpty()) break;
for (JsonNode item : list) {
Long kdtId = getLong(item, "kdt_id");
int stockMode = item.has("stock_mode") ? item.get("stock_mode").asInt() : 0;
if (kdtId != null) {
modeMap.put(kdtId, stockMode);
}
}
// 分页: 用 paginator.total_count
JsonNode paginator = data.get("paginator");
int totalCount = (paginator != null && paginator.has("total_count"))
? paginator.get("total_count").asInt(0) : 0;
if (pageNo * pageSize >= totalCount) break;
pageNo++;
} catch (Exception e) {
log.warn("[StoreSyncService] 库存模式查询异常: {}", e.getMessage());
break;
}
}
return modeMap;
}
private String getText(JsonNode node, String field) {
return (node != null && node.has(field) && !node.get(field).isNull())
? node.get(field).asText() : null;
}
private Long getLong(JsonNode node, String field) {
return (node != null && node.has(field) && !node.get(field).isNull())
? node.get(field).asLong() : null;
}
}

View File

@@ -0,0 +1,256 @@
package com.chuyishidai.datahub.service.sync;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.chuyishidai.datahub.entity.TradeOrderDetail;
import com.chuyishidai.datahub.mapper.TradeOrderDetailMapper;
import com.chuyishidai.datahub.service.youzan.YouzanApiClient;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* 订单同步服务 (Phase 1 核心)
*
* 官方文档要点:
* - GET https://open.youzanyun.com/api/youzan.trades.sold.get/4.0.4
* - 分页: page_no (1开始, 最大100页), page_size (最大100)
* - 翻页判断: data.has_next (Boolean)
* - 延迟建议: 查询时间窗口整体向后延迟 30 秒
* - 大批量拉取: 必须用时间切片法,不能单靠翻页
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TradeSyncService {
private final YouzanApiClient apiClient;
private final TradeOrderDetailMapper orderDetailMapper;
private static final String API_NAME = "youzan.trades.sold.get";
private static final String API_VERSION = "4.0.4";
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 执行增量同步
*
* @param startTime 窗口开始时间
* @param endTime 窗口结束时间
* @param pageSize 每页条数 (最大100)
* @return 本次同步的明细总条数
*/
public int syncTradesByUpdateTime(LocalDateTime startTime, LocalDateTime endTime, int pageSize) {
log.info("[TradeSyncService] 开始同步订单, 时间窗口: {} ~ {}", startTime.format(FMT), endTime.format(FMT));
int effectivePageSize = Math.min(pageSize, 100);
int totalSynced = 0;
int pageNo = 1;
while (true) {
// 官方文档: page_no 不能超过 100
if (pageNo > 100) {
log.warn("[TradeSyncService] 已达最大页码100, 需要缩小时间窗口! 已同步{}条", totalSynced);
break;
}
Map<String, Object> params = new HashMap<>();
params.put("start_created", startTime.format(FMT));
params.put("end_created", endTime.format(FMT));
params.put("page_no", pageNo);
params.put("page_size", effectivePageSize);
JsonNode data = apiClient.callApi(API_NAME, API_VERSION, params);
if (data == null) {
log.warn("[TradeSyncService] API 返回 data 为空, 同步结束");
break;
}
JsonNode orderList = data.get("full_order_info_list");
if (orderList == null || orderList.isEmpty()) {
log.info("[TradeSyncService] 第{}页无数据, 同步结束", pageNo);
break;
}
// 记录总数用于分页判断
int totalResults = data.has("total_results") ? data.get("total_results").asInt(0) : 0;
int pageSynced = 0;
for (JsonNode orderWrapper : orderList) {
JsonNode fullOrderInfo = orderWrapper.get("full_order_info");
if (fullOrderInfo == null) continue;
List<TradeOrderDetail> details = parseOrderToDetails(fullOrderInfo);
for (TradeOrderDetail detail : details) {
upsertOrderDetail(detail);
pageSynced++;
}
}
totalSynced += pageSynced;
log.info("[TradeSyncService] 第{}/{}页同步完成, 本页{}条明细 (total_results={})",
pageNo, (totalResults + effectivePageSize - 1) / effectivePageSize, pageSynced, totalResults);
// 分页判断: 不依赖 has_next (有赞API的has_next经常返回false)
// 改用 total_results 判断是否还有更多数据
int fetchedOrders = pageNo * effectivePageSize;
if (fetchedOrders >= totalResults) {
log.info("[TradeSyncService] 已拉取全部订单 ({}/{}), 同步结束", fetchedOrders, totalResults);
break;
}
pageNo++;
}
log.info("[TradeSyncService] 同步完成, 共{}条明细", totalSynced);
return totalSynced;
}
/**
* 解析一条完整订单 → 多条 SKU 粒度明细
*/
private List<TradeOrderDetail> parseOrderToDetails(JsonNode fullOrderInfo) {
List<TradeOrderDetail> details = new ArrayList<>();
JsonNode orderInfo = fullOrderInfo.get("order_info");
JsonNode buyerInfo = fullOrderInfo.get("buyer_info");
JsonNode payInfo = fullOrderInfo.get("pay_info");
JsonNode sourceInfo = fullOrderInfo.get("source_info");
JsonNode orders = fullOrderInfo.get("orders");
if (orderInfo == null) return details;
// 订单级公共字段
String tid = getText(orderInfo, "tid");
String status = getText(orderInfo, "status");
int refundState = getInt(orderInfo, "refund_state");
int orderType = getInt(orderInfo, "type");
boolean isRetail = orderInfo.has("is_retail_order") && orderInfo.get("is_retail_order").asBoolean();
// 门店ID: 优先使用 offline_id与 node_kdt_id 相同)
Long offlineId = getLong(orderInfo, "offline_id");
if (offlineId == null) offlineId = getLong(orderInfo, "node_kdt_id");
// shop_org_id 在 order_info.shop_display_no 中
String shopOrgId = getText(orderInfo, "shop_display_no");
LocalDateTime payTime = parseDateTime(getText(orderInfo, "pay_time"));
LocalDateTime createdTime = parseDateTime(getText(orderInfo, "created"));
LocalDateTime updateTime = parseDateTime(getText(orderInfo, "update_time"));
LocalDateTime successTime = parseDateTime(getText(orderInfo, "success_time"));
// 买家信息 (外卖订单可能无 buyer_phone)
String buyerPhone = buyerInfo != null ? getText(buyerInfo, "buyer_phone") : null;
String yzOpenId = buyerInfo != null ? getText(buyerInfo, "yz_open_id") : null;
Long fansId = buyerInfo != null ? getLong(buyerInfo, "fans_id") : null;
// 来源平台: order_mark 比 source.platform 更有实际意义
// order_mark = "meituan" / "eleme" / 空(自有渠道)
String sourcePlatform = null;
if (sourceInfo != null) {
sourcePlatform = getText(sourceInfo, "order_mark");
if (sourcePlatform == null && sourceInfo.has("source")) {
sourcePlatform = getText(sourceInfo.get("source"), "platform");
}
}
// 展开每一条商品明细
if (orders != null) {
for (JsonNode order : orders) {
TradeOrderDetail detail = new TradeOrderDetail();
detail.setTid(tid);
detail.setOid(getText(order, "oid"));
detail.setBuyerPhone(buyerPhone);
detail.setYzOpenId(yzOpenId);
detail.setFansId(fansId);
detail.setOfflineId(offlineId);
detail.setShopOrgId(shopOrgId);
detail.setItemId(getLongOrZero(order, "item_id"));
detail.setSkuId(getLongOrZero(order, "sku_id"));
detail.setOuterItemId(getText(order, "outer_item_id"));
detail.setOuterSkuId(getText(order, "outer_sku_id"));
detail.setTitle(getText(order, "title"));
detail.setPrice(getDecimal(order, "price"));
detail.setNum(getInt(order, "num"));
detail.setTotalFee(getDecimal(order, "total_fee"));
detail.setDiscountPrice(getDecimal(order, "discount_price"));
detail.setPayment(getDecimal(order, "payment"));
detail.setStatus(status);
detail.setRefundState(refundState);
detail.setSourcePlatform(sourcePlatform);
detail.setIsRetailOrder(isRetail);
detail.setOrderType(orderType);
detail.setPayTime(payTime);
detail.setCreatedTime(createdTime);
detail.setUpdateTime(updateTime);
detail.setSuccessTime(successTime);
details.add(detail);
}
}
return details;
}
/**
* UPSERT基于 oid 唯一键,存在则更新,不存在则插入
*/
private void upsertOrderDetail(TradeOrderDetail detail) {
LambdaQueryWrapper<TradeOrderDetail> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(TradeOrderDetail::getOid, detail.getOid());
TradeOrderDetail existing = orderDetailMapper.selectOne(wrapper);
if (existing != null) {
detail.setId(existing.getId());
orderDetailMapper.updateById(detail);
} else {
orderDetailMapper.insert(detail);
}
}
// ========== JSON 工具方法 ==========
private String getText(JsonNode node, String field) {
if (node == null || !node.has(field) || node.get(field).isNull()) return null;
String text = node.get(field).asText();
return text.isEmpty() ? null : text;
}
private int getInt(JsonNode node, String field) {
return (node != null && node.has(field)) ? node.get(field).asInt(0) : 0;
}
private Long getLong(JsonNode node, String field) {
if (node == null || !node.has(field) || node.get(field).isNull()) return null;
long val = node.get(field).asLong(0);
return val == 0 ? null : val;
}
/** 用于 NOT NULL BIGINT 字段 (item_id, sku_id),返回 0 而非 null */
private long getLongOrZero(JsonNode node, String field) {
return (node != null && node.has(field)) ? node.get(field).asLong(0) : 0L;
}
private BigDecimal getDecimal(JsonNode node, String field) {
if (node == null || !node.has(field) || node.get(field).isNull()) return BigDecimal.ZERO;
try {
return new BigDecimal(node.get(field).asText("0"));
} catch (NumberFormatException e) {
return BigDecimal.ZERO;
}
}
private LocalDateTime parseDateTime(String text) {
if (text == null || text.isBlank()) return null;
try {
return LocalDateTime.parse(text, FMT);
} catch (Exception e) {
return null;
}
}
}

View File

@@ -0,0 +1,157 @@
package com.chuyishidai.datahub.service.youzan;
import com.chuyishidai.datahub.config.YouzanConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.util.Map;
import java.util.StringJoiner;
/**
* 有赞开放平台 API 通用调用客户端
*
* 支持两种调用方式:
* 1. GET: callApi() — 参数拼接在 URL 上(如 trades.sold.get, item.search
* 2. POST: callApiPost() — 参数放在 JSON Body 中(如 scrm.customer.detail.get
*
* 错误响应有两种格式:
* 1. 网关错误: { "gw_err_resp": { "err_code": 4007, "err_msg": "..." } }
* 2. 业务错误: { "success": false, "code": 106000001, "message": "..." }
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class YouzanApiClient {
private final YouzanConfig youzanConfig;
private final YouzanTokenService tokenService;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
private static final int MAX_RETRIES = 3;
/**
* 调用有赞 API (GET 方式,参数拼接在 URL)
*/
public JsonNode callApi(String apiName, String version, Map<String, Object> params) {
return doCallWithRetry(apiName, version, params, false);
}
/**
* 调用有赞 API (POST 方式,参数放在 JSON Body)
* 适用于 scrm.customer.detail.get 等严格要求 POST 的接口
*/
public JsonNode callApiPost(String apiName, String version, Map<String, Object> params) {
return doCallWithRetry(apiName, version, params, true);
}
private JsonNode doCallWithRetry(String apiName, String version, Map<String, Object> params, boolean usePost) {
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
String responseBody;
if (usePost) {
// POST: access_token 在 URL 上,业务参数在 Body 中
String url = youzanConfig.getBaseUrl() + "/api/" + apiName + "/" + version
+ "?access_token=" + tokenService.getAccessToken();
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
String body = objectMapper.writeValueAsString(params != null ? params : Map.of());
HttpEntity<String> request = new HttpEntity<>(body, headers);
log.debug("[YouzanAPI] POST {} attempt {}, URL: {}, Body: {}", apiName, attempt, url, body);
ResponseEntity<String> resp = restTemplate.postForEntity(url, request, String.class);
responseBody = resp.getBody();
// DEBUG: 库存API原始响应写入文件
if (apiName.contains("warehousestock")) {
try {
java.nio.file.Files.writeString(java.nio.file.Path.of("inventory_raw_resp.txt"),
"URL: " + url.replace(tokenService.getAccessToken(), "***") +
"\nBody: " + body + "\nResponse: " + responseBody);
} catch (Exception ignored) {}
}
} else {
// GET: 所有参数拼接在 URL 上
String url = buildRawUrl(apiName, version, params);
URI uri = new URI(url.replace(" ", "%20"));
log.debug("[YouzanAPI] GET {} attempt {}, URL: {}", apiName, attempt, uri);
responseBody = restTemplate.getForObject(uri, String.class);
}
JsonNode root = objectMapper.readTree(responseBody);
return parseResponse(apiName, root, attempt);
} catch (RuntimeException e) {
if (attempt == MAX_RETRIES || !isRetryableException(e)) throw e;
} catch (Exception e) {
log.error("[YouzanAPI] {} attempt {} exception", apiName, attempt, e);
if (attempt == MAX_RETRIES) {
throw new RuntimeException("Youzan API exception: " + apiName, e);
}
}
}
throw new RuntimeException("Youzan API max retries exceeded: " + apiName);
}
private JsonNode parseResponse(String apiName, JsonNode root, int attempt) throws InterruptedException {
// 检查网关级错误 (gw_err_resp)
if (root.has("gw_err_resp")) {
JsonNode gwErr = root.get("gw_err_resp");
int errCode = gwErr.get("err_code").asInt();
String errMsg = gwErr.has("err_msg") ? gwErr.get("err_msg").asText() : "unknown";
log.error("[YouzanAPI] {} gateway error: code={}, msg={}", apiName, errCode, errMsg);
throw new RuntimeException("Youzan gateway error: " + errCode + " - " + errMsg);
}
// 检查业务级错误 (success=false)
boolean success = root.has("success") && root.get("success").asBoolean(false);
int code = root.has("code") ? root.get("code").asInt() : -1;
if (success && code == 200) {
return root.get("data");
}
String message = root.has("message") ? root.get("message").asText() : "unknown";
if (isRetryableError(code)) {
log.warn("[YouzanAPI] {} retryable error: code={}, msg={}, attempt {}", apiName, code, message, attempt);
Thread.sleep(1000L * attempt);
throw new RetryableException("retryable: " + code);
}
log.error("[YouzanAPI] {} business error: code={}, msg={}", apiName, code, message);
throw new RuntimeException("Youzan API error: " + apiName + " - " + code + " - " + message);
}
private String buildRawUrl(String apiName, String version, Map<String, Object> params) {
StringJoiner joiner = new StringJoiner("&");
joiner.add("access_token=" + tokenService.getAccessToken());
if (params != null) {
params.forEach((key, value) -> {
if (value != null) {
joiner.add(key + "=" + value.toString());
}
});
}
return youzanConfig.getBaseUrl() + "/api/" + apiName + "/" + version + "?" + joiner;
}
private boolean isRetryableError(int code) {
return code == 106100118 || code == 106000003;
}
private boolean isRetryableException(RuntimeException e) {
return e instanceof RetryableException;
}
private static class RetryableException extends RuntimeException {
RetryableException(String msg) { super(msg); }
}
}

View File

@@ -0,0 +1,122 @@
package com.chuyishidai.datahub.service.youzan;
import com.chuyishidai.datahub.config.YouzanConfig;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* 有赞自用型应用 Token 管理
* authorize_type=silent使用 client_id + client_secret + grant_id(kdt_id) 获取
*
* 官方文档要点:
* - POST https://open.youzanyun.com/auth/token
* - Content-Type: application/json
* - 响应: data.access_token + data.expires_in (秒)
* - 刷新策略:自用型直接重新请求(方式一),无需 refresh_token
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class YouzanTokenService {
private final YouzanConfig youzanConfig;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
/** 缓存的 access_token */
private volatile String accessToken;
/** token 过期时间戳 (毫秒) */
private volatile long expireAtMs = 0;
/**
* 获取有效的 access_token线程安全到期前 30 分钟自动刷新)
*/
public synchronized String getAccessToken() {
long nowMs = System.currentTimeMillis();
if (accessToken == null || nowMs >= expireAtMs) {
refreshToken();
}
return accessToken;
}
private void refreshToken() {
String url = youzanConfig.getBaseUrl() + "/auth/token";
// 官方文档JSON bodyauthorize_type=silentgrant_id=kdt_id
Map<String, Object> body = Map.of(
"client_id", youzanConfig.getClientId(),
"client_secret", youzanConfig.getClientSecret(),
"authorize_type", "silent",
"grant_id", youzanConfig.getKdtId(),
"refresh", true
);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
try {
String jsonBody = objectMapper.writeValueAsString(body);
HttpEntity<String> request = new HttpEntity<>(jsonBody, headers);
log.info("[YouzanToken] 正在获取 Token, grant_id={}", youzanConfig.getKdtId());
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
log.debug("[YouzanToken] 响应: {}", response.getBody());
JsonNode root = objectMapper.readTree(response.getBody());
// 检查 success 标记
boolean success = root.has("success") && root.get("success").asBoolean(false);
if (!success) {
String msg = root.has("message") ? root.get("message").asText() : response.getBody();
log.error("[YouzanToken] Token 获取失败: {}", msg);
throw new RuntimeException("获取有赞Token失败: " + msg);
}
JsonNode data = root.get("data");
if (data != null && data.has("access_token")) {
this.accessToken = data.get("access_token").asText();
// 官方文档说明:
// - data.expires_in 是有效时长(秒),通常 604800 即 7 天
// - 但 SDK 实测返回的 data.expires 是毫秒级绝对时间戳
// 兼容两种格式
long nowMs = System.currentTimeMillis();
if (data.has("expires_in")) {
// 标准文档格式:有效时长(秒)
long expiresInSec = data.get("expires_in").asLong();
long ttlMs = expiresInSec * 1000;
// 提前 30 分钟刷新
this.expireAtMs = nowMs + ttlMs - 30 * 60 * 1000;
log.info("[YouzanToken] Token 刷新成功 (expires_in 模式), 有效期 {} 小时", expiresInSec / 3600);
} else if (data.has("expires")) {
// SDK 返回格式:绝对毫秒时间戳
long expiresMs = data.get("expires").asLong();
// 提前 30 分钟刷新
this.expireAtMs = expiresMs - 30 * 60 * 1000;
log.info("[YouzanToken] Token 刷新成功 (expires 模式), 有效期 {} 小时",
(expiresMs - nowMs) / 3600000);
} else {
// 默认 7 天
this.expireAtMs = nowMs + 7 * 24 * 3600 * 1000L - 30 * 60 * 1000;
log.warn("[YouzanToken] 响应中无 expires 字段, 默认 7 天有效期");
}
} else {
log.error("[YouzanToken] 响应中无 access_token, 响应: {}", response.getBody());
throw new RuntimeException("获取有赞Token失败: 响应中无 access_token");
}
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
log.error("[YouzanToken] Token 刷新异常", e);
throw new RuntimeException("刷新有赞Token异常", e);
}
}
}

View File

@@ -0,0 +1,70 @@
# ========================================
# 生产环境配置 (服务器部署用)
# 启动方式: java -jar datahub.jar --spring.profiles.active=prod
# ========================================
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/youzandatahub?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: datahub_user
password: Datahub@2026
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
# MyBatis-Plus
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
# 生产环境关闭SQL日志
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
# 有赞云 API 配置
youzan:
api:
base-url: https://open.youzanyun.com
client-id: f6eb734c712329dc5e
client-secret: f7fd462479075645d38a460ceb1c2e47
kdt-id: 154234003
# 同步任务配置
sync:
trade:
cron: "0 0 2 * * ?" # 每日凌晨 2:00 订单同步
page-size: 100
overlap-minutes: 30
item:
cron: "0 30 2 * * ?" # 每日凌晨 2:30 商品同步
customer:
cron: "0 45 2 * * ?" # 每日凌晨 2:45 客户聚合
store:
cron: "0 0 3 * * ?" # 每日凌晨 3:00 门店同步
refund:
cron: "0 10 3 * * ?" # 每日凌晨 3:10 退款同步
inventory:
cron: "0 20 3 * * ?" # 每日凌晨 3:20 库存快照
insight:
cron: "0 0 4 * * ?" # 每日凌晨 4:00 洞察计算 (RFM+复购+购物篮+标签)
# 生产日志: 只输出 WARN 以上, datahub 包 INFO
logging:
level:
root: WARN
com.chuyishidai.datahub: INFO
file:
name: logs/datahub.log
logback:
rollingpolicy:
max-file-size: 50MB
max-history: 30

View File

@@ -0,0 +1,54 @@
server:
port: 8080
spring:
application:
name: datahub
datasource:
url: jdbc:mysql://192.168.70.58:3306/youzandatahub?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
# MyBatis-Plus
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
id-type: auto
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
# 有赞云 API 配置 (自用型应用)
youzan:
api:
base-url: https://open.youzanyun.com
client-id: f6eb734c712329dc5e
client-secret: f7fd462479075645d38a460ceb1c2e47
kdt-id: 154234003
# 同步任务配置
sync:
trade:
cron: "0 0 2 * * ?" # 每日凌晨 2:00 订单同步
page-size: 100 # 每页拉取条数 (最大100)
overlap-minutes: 30 # 时间窗口重叠分钟数 (防遗漏)
item:
cron: "0 30 2 * * ?" # 每日凌晨 2:30 商品同步
customer:
cron: "0 45 2 * * ?" # 每日凌晨 2:45 客户聚合
store:
cron: "0 0 3 * * ?" # 每日凌晨 3:00 门店同步
refund:
cron: "0 10 3 * * ?" # 每日凌晨 3:10 退款同步
inventory:
cron: "0 20 3 * * ?" # 每日凌晨 3:20 库存快照
insight:
cron: "0 0 4 * * ?" # 每日凌晨 4:00 洞察计算 (RFM+复购+购物篮+标签)
logging:
level:
com.chuyishidai.datahub: DEBUG

View File

@@ -0,0 +1,493 @@
// API Endpoint Config (相对路径, 同源部署无需跨域)
const BASE_URL = '/api';
// 数据截止时间缓存
let dataCutoffCache = {};
// ECharts Instances
let trendChart;
let tagChart;
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initCharts();
setupNavigation();
// Fetch cutoff dates + initial page data
fetchDataCutoff();
fetchData();
});
// Setup SPA Navigation
function setupNavigation() {
const navItems = document.querySelectorAll('.nav-item');
const sections = {
'大盘总览': 'page-overview',
'商品洞察': 'page-product',
'会员画像': 'page-customer',
'订货建议': 'page-advice',
'运维管理': 'page-ops'
};
// 页面名 → 标题文本 映射
const pageTitles = {
'大盘总览': '经营数据大盘概览',
'商品洞察': '商品洞察分析',
'会员画像': '会员画像分析',
'订货建议': '智能订货建议',
'运维管理': '运维管理'
};
// 页面名 → 截止日期 key 映射
const cutoffKeys = {
'大盘总览': 'overview',
'商品洞察': 'product',
'会员画像': 'customer',
'订货建议': 'advice'
};
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
navItems.forEach(nav => nav.classList.remove('active'));
item.classList.add('active');
const pageTitle = item.textContent.trim();
document.getElementById('page-title').textContent = pageTitles[pageTitle] || pageTitle;
// 更新数据截止日期
const badge = document.getElementById('data-cutoff-badge');
const cutoffKey = cutoffKeys[pageTitle];
if (cutoffKey && dataCutoffCache[cutoffKey]) {
badge.textContent = `计算数据截止至 ${dataCutoffCache[cutoffKey]}`;
badge.style.display = '';
} else if (pageTitle === '运维管理') {
badge.style.display = 'none';
} else {
badge.textContent = '计算数据截止至 —';
badge.style.display = '';
}
document.querySelectorAll('.page-section').forEach(sec => sec.style.display = 'none');
const targetId = sections[pageTitle];
if(targetId) {
document.getElementById(targetId).style.display = 'block';
}
fetchDataForPage(pageTitle);
});
});
}
// Initialize ECharts with premium minimalist styling
function initCharts() {
trendChart = echarts.init(document.getElementById('trendChart'), null, { renderer: 'svg' });
tagChart = echarts.init(document.getElementById('tagChart'), null, { renderer: 'svg' });
// Resize observer to keep chart responsive
window.addEventListener('resize', () => {
trendChart.resize();
tagChart.resize();
});
}
// Format currency
function formatCurrency(val) {
if (val === undefined || val === null) return '¥0.00';
return '¥' + parseFloat(val).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// Format percentages
function formatPercent(val) {
if (val === undefined || val === null) return '0%';
return (parseFloat(val) * 100).toFixed(1) + '%';
}
// Fetch all data for current active page
async function fetchData() {
const activeNav = document.querySelector('.nav-item.active');
if (activeNav) {
await fetchDataForPage(activeNav.textContent.trim());
}
}
async function fetchDataForPage(pageName) {
const btn = document.querySelector('.btn-refresh');
btn.textContent = '加载中...';
btn.classList.add('loading');
try {
if (pageName === '大盘总览') {
await Promise.all([fetchOverview(), fetchTrend()]);
} else if (pageName === '商品洞察') {
await Promise.all([fetchRepurchase(), fetchBasket()]);
} else if (pageName === '会员画像') {
await Promise.all([fetchRfm(), fetchTags()]);
// echarts requires explicit resize when un-hidden
setTimeout(() => tagChart.resize(), 100);
} else if (pageName === '订货建议') {
await fetchAdvice();
}
} catch (error) {
console.error("Failed to fetch data:", error);
} finally {
setTimeout(() => {
btn.textContent = '刷新数据';
btn.classList.remove('loading');
}, 500);
}
}
// ============== DATA CUTOFF ==============
async function fetchDataCutoff() {
try {
const response = await fetch(`${BASE_URL}/dashboard/data-cutoff`);
if (!response.ok) return;
dataCutoffCache = await response.json();
// 更新当前页面的截止日期
const badge = document.getElementById('data-cutoff-badge');
if (dataCutoffCache.overview) {
badge.textContent = `计算数据截止至 ${dataCutoffCache.overview}`;
}
} catch (e) { console.error('fetchDataCutoff error:', e); }
}
// ============== PAGE 1: OVERVIEW ==============
async function fetchOverview() {
try {
const response = await fetch(`${BASE_URL}/dashboard/overview`);
if (!response.ok) return;
const data = await response.json();
animateValue(document.getElementById('revenue-val'), data.past_14d_revenue || 0, true);
animateValue(document.getElementById('orders-val'), data.past_14d_orders || 0, false);
animateValue(document.getElementById('atv-val'), data.past_14d_atv || 0, true);
animateValue(document.getElementById('refund-val'), data.past_14d_refund || 0, true);
} catch (e) { console.error(e); }
}
async function fetchTrend() {
try {
const response = await fetch(`${BASE_URL}/dashboard/trend`);
if (!response.ok) return;
const data = await response.json();
const dates = data.map(item => item.stat_date);
const revenues = data.map(item => parseFloat(item.revenue));
const orders = data.map(item => parseInt(item.order_count));
updateTrendChart(dates, revenues, orders);
} catch (e) { console.error(e); }
}
// ============== PAGE 2: PRODUCT INSIGHTS ==============
async function fetchRepurchase() {
try {
const response = await fetch(`${BASE_URL}/product/repurchase`);
if (!response.ok) return;
const data = await response.json();
const tbody = document.querySelector('#repurchase-table tbody');
tbody.innerHTML = '';
if(data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center">暂无数据</td></tr>';
return;
}
data.forEach(item => {
const tr = document.createElement('tr');
// Badge color logic
let badgeClass = 'primary';
if(item.matrix_tag === '核心引流爆款') badgeClass = 'success';
if(item.matrix_tag === '体验差需淘汰') badgeClass = 'danger';
if(item.matrix_tag === '小众潜力款') badgeClass = 'warning';
tr.innerHTML = `
<td><strong>${item.item_name || ('商品 '+item.item_id)}</strong></td>
<td>${item.purchaser_count_30d} 人</td>
<td>${item.repurchaser_count_30d} 人</td>
<td>${formatPercent(item.repurchase_rate_30d)}</td>
<td><span class="badge ${badgeClass}">${item.matrix_tag || '-'}</span></td>
`;
tbody.appendChild(tr);
});
} catch (e) {}
}
async function fetchBasket() {
try {
const response = await fetch(`${BASE_URL}/product/basket`);
if (!response.ok) return;
const data = await response.json();
const tbody = document.querySelector('#basket-table tbody');
tbody.innerHTML = '';
if(data.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center">暂无数据</td></tr>';
return;
}
data.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${item.item_name_a}</strong></td>
<td>${item.item_name_b}</td>
<td>${item.pair_order_count} 笔</td>
<td><span class="badge primary">${formatPercent(item.confidence)}</span></td>
`;
tbody.appendChild(tr);
});
} catch (e) {}
}
// ============== PAGE 3: CUSTOMER INSIGHTS ==============
async function fetchRfm() {
try {
const response = await fetch(`${BASE_URL}/customer/rfm/distribution`);
if (!response.ok) return;
const data = await response.json();
const container = document.getElementById('rfm-cards');
container.innerHTML = '';
// RFM 分组 → 计算逻辑说明
const rfmTooltips = {
'重要价值客户': 'R(最近消费)高 + F(消费频次)高 + M(消费金额)高, 核心忠实客户',
'重要发展客户': 'R高 + F低 + M高, 消费金额大但频次不够, 需提升复购',
'重要保持客户': 'R低 + F高 + M高, 消费频次和金额都高但最近未消费, 需唤醒',
'重要挽留客户': 'R低 + F低 + M高, 曾经的高价值客户但已流失, 需重点挽回',
'一般价值客户': 'R高 + F高 + M低, 活跃但消费金额偏低',
'一般发展客户': 'R高 + F低 + M低, 新客户/偶发客户, 有发展潜力',
'一般保持客户': 'R低 + F高 + M低, 活跃但金额低且近期未消费',
'一般挽留客户': 'R低 + F低 + M低, 低价值流失客户',
};
data.forEach(item => {
let trendClass = 'neutral';
if(item.rfm_group.includes('重要')) trendClass = 'positive';
if(item.rfm_group.includes('沉睡')) trendClass = 'negative';
const tooltip = rfmTooltips[item.rfm_group] || 'RFM综合打分分群';
const card = document.createElement('div');
card.className = 'glass-card metric-card';
card.innerHTML = `
<h3>${item.rfm_group} <span class="info-icon" data-tooltip="${tooltip}">ⓘ</span></h3>
<div class="value">${item.customer_count} <span style="font-size:1rem;font-weight:400;">人</span></div>
<div class="trend ${trendClass}">营收贡献: ${formatCurrency(item.total_revenue_contribution)}</div>
`;
container.appendChild(card);
});
} catch (e) {}
}
async function fetchTags() {
try {
const response = await fetch(`${BASE_URL}/customer/tags`);
if (!response.ok) return;
const data = await response.json();
// 标签名 → 定义说明 (鼠标悬浮时显示)
const tagDefs = {
'工作日常客': '90天内周一~周五消费 ≥ 3 次',
'高客单囤货客': '有单笔订单实付 ≥ 200 元',
'周末活跃客': '90天内周六日消费 ≥ 3 次',
'新客户': '首单在近 30 天内',
'沉睡客户': '最近一次消费距今 > 60 天',
'高频复购客': '90天内消费 ≥ 10 次',
'多品类客户': '90天内购买 ≥ 3 种不同商品',
'大额客户': '历史累计消费 ≥ 1000 元',
};
const chartData = data.map(item => ({
value: item.tagged_users_count,
name: item.tag_name,
definition: tagDefs[item.tag_name] || ''
}));
updateTagChart(chartData);
} catch(e) {}
}
// ============== PAGE 4: ADVICE ==============
async function fetchAdvice() {
try {
const response = await fetch(`${BASE_URL}/product/advice`);
if (!response.ok) return;
const data = await response.json();
const tbody = document.querySelector('#advice-table tbody');
tbody.innerHTML = '';
if(data.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center">近14天数据不足无法生成建议</td></tr>';
return;
}
data.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><strong>${item.item_name || item.item_id}</strong></td>
<td>${item.sum_14d} 件</td>
<td>${parseFloat(item.avg_daily_speed).toFixed(2)} 件/天</td>
<td><span class="badge success">备货 ${item.advice_stock_qty} 件</span></td>
`;
tbody.appendChild(tr);
});
} catch (e) {}
}
// ============== CHART UPDATERS & UTILS ==============
function updateTrendChart(dates, revenues, orders) {
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const textColor = isDarkMode ? '#f5f5f7' : '#1d1d1f';
const gridLineColor = isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.04)';
trendChart.setOption({
tooltip: {
trigger: 'axis',
backgroundColor: isDarkMode ? 'rgba(28, 28, 30, 0.9)' : 'rgba(255, 255, 255, 0.9)',
borderColor: isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
textStyle: { color: textColor, fontFamily: 'Inter' },
backdropFilter: 'blur(20px)',
borderRadius: 12, padding: 12
},
legend: { data: ['销售额', '订单数'], textStyle: { color: '#86868b', fontFamily: 'Inter' }, top: 0, right: 0 },
grid: { left: '3%', right: '4%', bottom: '3%', top: '15%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: dates, axisLabel: { color: '#86868b' }, axisLine: { show: false }, axisTick: { show: false } },
yAxis: [
{ type: 'value', name: 'Revenue', splitLine: { lineStyle: { color: gridLineColor, type: 'dashed' } }, axisLabel: { color: '#86868b' } },
{ type: 'value', name: 'Orders', position: 'right', splitLine: { show: false }, axisLabel: { color: '#86868b' } }
],
series: [
{ name: '销售额', type: 'line', smooth: 0.4, symbol: 'none', itemStyle: { color: '#0071e3' }, lineStyle: { width: 3, shadowColor: 'rgba(0, 113, 227, 0.3)', shadowBlur: 10 },
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: 'rgba(0, 113, 227, 0.2)' }, { offset: 1, color: 'rgba(0, 113, 227, 0)' }]) },
data: revenues },
{ name: '订单数', type: 'line', yAxisIndex: 1, smooth: 0.4, symbol: 'none', itemStyle: { color: '#ff3b30' }, lineStyle: { width: 2, type: 'dashed' }, data: orders }
],
animationDuration: 1500
});
}
function updateTagChart(chartData) {
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const textColor = isDarkMode ? '#f5f5f7' : '#1d1d1f';
tagChart.setOption({
tooltip: {
trigger: 'item',
backgroundColor: isDarkMode ? 'rgba(28, 28, 30, 0.95)' : 'rgba(255, 255, 255, 0.95)',
textStyle: { color: textColor, fontFamily: 'Inter', fontSize: 13 },
borderColor: isDarkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
borderRadius: 12, padding: [12, 16],
formatter: function(params) {
const def = params.data.definition;
let html = `<strong style="font-size:14px">${params.name}</strong><br/>`;
html += `人数: <strong>${params.value}</strong> 人 (${params.percent}%)`;
if (def) {
html += `<br/><span style="color:#86868b;font-size:12px">定义: ${def}</span>`;
}
return html;
}
},
legend: { top: 'bottom', textStyle: { color: '#86868b', fontFamily: 'Inter' } },
series: [
{
name: '画像人数',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: isDarkMode ? '#1c1c1e' : '#fff',
borderWidth: 2
},
label: { show: false, position: 'center' },
emphasis: {
label: { show: true, fontSize: '18', fontWeight: 'bold', color: textColor }
},
labelLine: { show: false },
data: chartData,
color: ['#0071e3', '#34c759', '#ff9500', '#ff3b30', '#5856d6', '#ff2d55', '#30b0c7', '#a2845e']
}
]
});
}
function animateValue(obj, end, isCurrency) {
let startTimestamp = null;
const duration = 1000;
const target = parseFloat(end);
const step = (timestamp) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
const ease = 1 - Math.pow(1 - progress, 4);
const current = target * ease;
obj.innerHTML = isCurrency ? formatCurrency(current) : Math.floor(current);
if (progress < 1) window.requestAnimationFrame(step);
else obj.innerHTML = isCurrency ? formatCurrency(target) : target;
};
window.requestAnimationFrame(step);
}
// ============== PAGE 5: OPS MANAGEMENT ==============
function appendLog(logEl, emoji, text, cssClass) {
const entry = document.createElement('span');
entry.className = `log-entry ${cssClass || ''}`;
const time = new Date().toLocaleTimeString('zh-CN');
entry.innerHTML = `<span class="log-time">${time}</span>${emoji} ${text}`;
logEl.appendChild(entry);
logEl.scrollTop = logEl.scrollHeight;
}
async function triggerOps(action, btnElement) {
const logEl = document.getElementById('ops-log');
const taskName = btnElement.querySelector('.ops-label').textContent;
// Clear the initial placeholder text on first use
if (logEl.querySelector('.log-placeholder')) {
logEl.innerHTML = '';
}
// Disable button & show running state
btnElement.disabled = true;
btnElement.classList.add('ops-running');
const origDesc = btnElement.querySelector('.ops-desc').textContent;
btnElement.querySelector('.ops-desc').textContent = '执行中...';
appendLog(logEl, '⏳', `${taskName} 开始执行...`, 'log-pending');
try {
// 构建 URL: 订单/退款同步支持可选的 from 日期参数
let url = `${BASE_URL.replace('/api', '')}/api/ops/${action}`;
if (action === 'sync-orders' || action === 'sync-refunds') {
const fromDate = document.getElementById('ops-from-date')?.value;
if (fromDate) {
url += `?from=${fromDate}`;
appendLog(logEl, '📅', `使用指定日期: ${fromDate}`, 'log-pending');
}
}
const response = await fetch(url);
const data = await response.json();
if (data.status === 'success') {
const detail = data.message || (data.count !== undefined ? `处理 ${data.count}` : '完成');
appendLog(logEl, '✅', `${taskName} 成功 — ${detail}`, 'log-success');
btnElement.classList.add('ops-done');
} else {
appendLog(logEl, '❌', `${taskName} 失败: ${data.message || '未知错误'}`, 'log-error');
btnElement.classList.add('ops-error');
}
} catch (e) {
appendLog(logEl, '❌', `${taskName} 网络异常: ${e.message}`, 'log-error');
btnElement.classList.add('ops-error');
} finally {
btnElement.disabled = false;
btnElement.classList.remove('ops-running');
btnElement.querySelector('.ops-desc').textContent = origDesc;
setTimeout(() => {
btnElement.classList.remove('ops-done', 'ops-error');
}, 3000);
}
}

View File

@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>餐饮零售数据中台 | 核心引擎</title>
<!-- Google Fonts for Apple-like Typography -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
<!-- ECharts -->
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
</head>
<body>
<div class="app-container">
<!-- Sidebar Navigation -->
<aside class="sidebar">
<div class="logo">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 2 7 12 12 22 7 12 2"></polygon>
<polyline points="2 17 12 22 22 17"></polyline>
<polyline points="2 12 12 17 22 12"></polyline>
</svg>
<span>DataHub Core</span>
</div>
<nav class="nav-menu">
<a href="#" class="nav-item active">大盘总览</a>
<a href="#" class="nav-item">商品洞察</a>
<a href="#" class="nav-item">会员画像</a>
<a href="#" class="nav-item">订货建议</a>
<a href="#" class="nav-item">运维管理</a>
</nav>
</aside>
<!-- Main Content -->
<main class="main-content">
<header class="top-nav">
<div class="page-title-row">
<h1 id="page-title">经营数据大盘概览</h1>
<span id="data-cutoff-badge" class="data-cutoff-badge">计算数据截止至 —</span>
</div>
<div class="header-actions">
<button class="btn-refresh" onclick="fetchData()">刷新数据</button>
<div class="user-profile">
<img src="https://ui-avatars.com/api/?name=Admin&background=000&color=fff" alt="Admin">
</div>
</div>
</header>
<!-- Page 1: 大盘总览 -->
<div id="page-overview" class="page-section active">
<div class="dashboard-grid">
<!-- Overview Cards -->
<div class="metrics-row">
<div class="glass-card metric-card">
<h3>过去14天总营收 <span class="info-icon" data-tooltip="SUM(实付金额), 仅统计 TRADE_SUCCESS 状态的订单, 时间范围: 近14天按 pay_time 筛选"></span></h3>
<div class="value" id="revenue-val">¥0.00</div>
<div class="trend positive"><span></span> 实时</div>
</div>
<div class="glass-card metric-card">
<h3>过去14天订单数 <span class="info-icon" data-tooltip="COUNT(DISTINCT tid), 近14天内有 pay_time 的不重复订单号数量"></span></h3>
<div class="value" id="orders-val">0</div>
<div class="trend positive"><span></span> 实时</div>
</div>
<div class="glass-card metric-card">
<h3>平均客单价 (ATV) <span class="info-icon" data-tooltip="= 过去14天总营收 ÷ 过去14天订单数, 每笔订单的平均消费金额"></span></h3>
<div class="value" id="atv-val">¥0.00</div>
<div class="trend neutral">稳定</div>
</div>
<div class="glass-card metric-card">
<h3>过去14天退款额 <span class="info-icon" data-tooltip="SUM(refund_fee), 近14天内创建的退款单退款金额总计"></span></h3>
<div class="value" id="refund-val">¥0.00</div>
<div class="trend negative"><span></span> 风控监测中</div>
</div>
</div>
<!-- Charts Area -->
<div class="charts-row">
<div class="glass-card chart-container">
<div class="chart-header">
<h2>14天营收曲线 (Sales Trend) <span class="info-icon" data-tooltip="X轴=日期, 蓝线=当日SUM(payment), 红虚线=当日COUNT(DISTINCT tid), 按 pay_time 分组"></span></h2>
</div>
<div id="trendChart" style="width: 100%; height: 350px;"></div>
</div>
</div>
</div>
</div>
<!-- Page 2: 商品洞察 -->
<div id="page-product" class="page-section" style="display: none;">
<div class="dashboard-grid">
<div class="glass-card chart-container">
<div class="chart-header">
<h2>商品30天复购率与波士顿矩阵 <span class="info-icon" data-tooltip="每日洞察计算任务生成, 统计近30天各商品的购买/复购人数"></span></h2>
</div>
<div class="table-responsive">
<table class="data-table" id="repurchase-table">
<thead>
<tr>
<th>商品名称</th>
<th>30天购买人数 <span class="info-icon info-icon-sm" data-tooltip="COUNT(DISTINCT yz_open_id), 近30天购买过该商品的不重复客户数"></span></th>
<th>30天复购人数 <span class="info-icon info-icon-sm" data-tooltip="近30天内购买该商品≥2次的客户数"></span></th>
<th>复购率 <span class="info-icon info-icon-sm" data-tooltip="= 复购人数 ÷ 购买人数 × 100%"></span></th>
<th>波士顿矩阵打标 <span class="info-icon info-icon-sm" data-tooltip="购买人数高+复购率高=核心引流爆款; 购买人数高+复购率低=体验差需淘汰; 购买人数低+复购率高=小众潜力款; 其他=长尾观测品"></span></th>
</tr>
</thead>
<tbody>
<tr><td colspan="5" class="text-center">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="glass-card chart-container">
<div class="chart-header">
<h2>购物篮热门连带趋势 (Apriori Top20) <span class="info-icon" data-tooltip="Apriori关联规则挖掘, 基于近30天同一订单(tid)内共同出现的商品对"></span></h2>
</div>
<div class="table-responsive">
<table class="data-table" id="basket-table">
<thead>
<tr>
<th>核心商品 A</th>
<th>连带商品 B</th>
<th>共同出现订单数 <span class="info-icon info-icon-sm" data-tooltip="在同一笔订单中同时购买A和B的订单数"></span></th>
<th>搭配置信度 <span class="info-icon info-icon-sm" data-tooltip="= 同时购买A和B的订单数 ÷ 购买A的订单数 × 100%, 即买了A的人有多大概率也买B"></span></th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-center">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Page 3: 会员画像 -->
<div id="page-customer" class="page-section" style="display: none;">
<div class="dashboard-grid">
<div class="metrics-row" id="rfm-cards">
<!-- RFM Cards rendered via JS -->
</div>
<div class="glass-card chart-container">
<div class="chart-header">
<h2>全域会员标签分布 <span class="info-icon" data-tooltip="基于 RFM 模型对客户分群打标后, 各标签的人数分布饼图"></span></h2>
</div>
<div id="tagChart" style="width: 100%; height: 350px;"></div>
</div>
</div>
</div>
<!-- Page 4: 订货建议 -->
<div id="page-advice" class="page-section" style="display: none;">
<div class="glass-card chart-container">
<div class="chart-header">
<h2>次日智能安全备货单 (过去14天流速推断) <span class="info-icon" data-tooltip="基于近14天每日平均销量(日均流速), 计算3天安全备货量"></span></h2>
</div>
<div class="table-responsive">
<table class="data-table" id="advice-table">
<thead>
<tr>
<th>商品名称</th>
<th>14天总销量 <span class="info-icon info-icon-sm" data-tooltip="近14天该商品的订单明细数量合计 (件数)"></span></th>
<th>日均流速 <span class="info-icon info-icon-sm" data-tooltip="= 14天总销量 ÷ 14, 平均每天销售几件"></span></th>
<th>建议安全补货量 (3天量) <span class="info-icon info-icon-sm" data-tooltip="= CEIL(日均流速 × 3), 向上取整确保安全库存"></span></th>
</tr>
</thead>
<tbody>
<tr><td colspan="4" class="text-center">加载中...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Page 5: 运维管理 -->
<div id="page-ops" class="page-section" style="display: none;">
<div class="dashboard-grid">
<div class="glass-card chart-container">
<div class="chart-header">
<h2>数据同步 & 洞察计算 手动触发</h2>
</div>
<p style="color: #86868b; margin: 0 0 1.5rem 0; font-size: 0.9rem;">
以下操作与每日定时任务相同,点击后将实时执行并返回结果。执行耗时视数据量而定,请耐心等待。
</p>
<!-- 日期选择器: 订单/退款同步可选起始日期 -->
<div class="ops-date-row">
<label for="ops-from-date" class="ops-date-label">指定起始日期</label>
<input type="date" id="ops-from-date" class="ops-date-input" />
<span class="ops-date-hint">留空 = 自动检测缺口 (智能模式)</span>
</div>
<div class="ops-grid">
<button class="ops-btn" onclick="triggerOps('sync-orders', this)">
<span class="ops-icon">📦</span>
<span class="ops-label">订单同步</span>
<span class="ops-desc">智能增量 / 指定日期</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-refunds', this)">
<span class="ops-icon">💰</span>
<span class="ops-label">退款同步</span>
<span class="ops-desc">智能增量 / 指定日期</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-items', this)">
<span class="ops-icon">🍱</span>
<span class="ops-label">商品同步</span>
<span class="ops-desc">全量商品目录</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-stores', this)">
<span class="ops-icon">🏪</span>
<span class="ops-label">门店同步</span>
<span class="ops-desc">门店与网点</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-inventory', this)">
<span class="ops-icon">📊</span>
<span class="ops-label">库存快照</span>
<span class="ops-desc">各门店库存</span>
</button>
<button class="ops-btn" onclick="triggerOps('sync-customers', this)">
<span class="ops-icon">👥</span>
<span class="ops-label">客户聚合</span>
<span class="ops-desc">订单驱动聚合</span>
</button>
<button class="ops-btn ops-btn-accent" onclick="triggerOps('compute-insights', this)">
<span class="ops-icon">🧠</span>
<span class="ops-label">洞察计算</span>
<span class="ops-desc">RFM+复购+购物篮</span>
</button>
</div>
<div class="ops-log-container">
<h3 style="margin: 0 0 0.5rem 0;">执行日志</h3>
<div id="ops-log" class="ops-log"><span class="log-entry log-placeholder"><span class="log-time"></span>等待操作...</span></div>
</div>
</div>
</div>
</div>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,723 @@
/* Modern Apple-like Tech Aesthetic Variables */
:root {
--bg-base: #fbfbfd;
--text-primary: #1d1d1f;
--text-secondary: #86868b;
--accent-blue: #0071e3;
--accent-green: #34c759;
--accent-red: #ff3b30;
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-border: rgba(255, 255, 255, 0.5);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.04);
--transition-smooth: all 0.5s cubic-bezier(0.25, 1, 0.5, 1);
}
/* Dark Mode Query Support */
@media (prefers-color-scheme: dark) {
:root {
--bg-base: #000000;
--text-primary: #f5f5f7;
--text-secondary: #86868b;
--accent-blue: #2997ff;
--glass-bg: rgba(28, 28, 30, 0.6);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
body {
background: radial-gradient(circle at top right, #1d1d27, #000000) !important;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Helvetica Neue", sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
background: var(--bg-base);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
/* Soft mesh gradient background for premium feel */
background: radial-gradient(circle at top right, #e2eafc, #fbfbfd 60%);
}
.app-container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 240px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--glass-border);
padding: 2rem 0;
display: flex;
flex-direction: column;
z-index: 10;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
font-weight: 600;
font-size: 1.1rem;
letter-spacing: -0.5px;
margin-bottom: 2.5rem;
color: var(--text-primary);
}
.nav-menu {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 12px;
}
.nav-item {
text-decoration: none;
color: var(--text-secondary);
padding: 10px 16px;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
transition: var(--transition-smooth);
}
.nav-item:hover {
background: rgba(134, 134, 139, 0.1);
color: var(--text-primary);
transform: translateX(4px);
}
.nav-item.active {
background: var(--text-primary);
color: var(--bg-base);
}
/* Main Content */
.main-content {
flex: 1;
overflow-y: auto;
padding: 2rem 3rem;
}
.top-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
/* Page title row: title + cutoff badge inline */
.page-title-row {
display: flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.page-title-row h1 {
margin: 0;
}
/* Data cutoff date badge */
.data-cutoff-badge {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
background: rgba(0, 113, 227, 0.06);
border: 1px solid rgba(0, 113, 227, 0.12);
border-radius: 20px;
padding: 4px 14px;
white-space: nowrap;
line-height: 1.4;
}
/* ============== Info Icon Tooltip ============== */
.info-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 0.72rem;
font-style: normal;
color: var(--text-secondary);
background: rgba(134, 134, 139, 0.08);
border: 1px solid rgba(134, 134, 139, 0.15);
border-radius: 50%;
cursor: help;
position: relative;
vertical-align: middle;
margin-left: 4px;
transition: all 0.2s;
}
.info-icon:hover {
color: var(--accent-blue);
background: rgba(0, 113, 227, 0.08);
border-color: rgba(0, 113, 227, 0.3);
}
/* Smaller variant for table headers */
.info-icon-sm {
width: 15px;
height: 15px;
font-size: 0.65rem;
}
/* Tooltip popup */
.info-icon::after {
content: attr(data-tooltip);
position: absolute;
bottom: 130%;
left: 50%;
transform: translateX(-50%);
background: rgba(28, 28, 30, 0.92);
color: #f5f5f7;
font-size: 0.75rem;
font-weight: 400;
line-height: 1.5;
padding: 8px 12px;
border-radius: 10px;
width: max-content;
max-width: 320px;
white-space: normal;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 100;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(12px);
}
/* Tooltip arrow */
.info-icon::before {
content: '';
position: absolute;
bottom: 120%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: rgba(28, 28, 30, 0.92);
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
z-index: 101;
}
.info-icon:hover::after,
.info-icon:hover::before {
opacity: 1;
visibility: visible;
}
@media (prefers-color-scheme: dark) {
.data-cutoff-badge {
background: rgba(0, 113, 227, 0.1);
border-color: rgba(0, 113, 227, 0.2);
}
.info-icon {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.1);
}
.info-icon::after {
background: rgba(44, 44, 46, 0.95);
}
.info-icon::before {
border-top-color: rgba(44, 44, 46, 0.95);
}
}
}
.top-nav h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -1px;
}
.header-actions {
display: flex;
align-items: center;
gap: 20px;
}
.btn-refresh {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
backdrop-filter: blur(10px);
transition: var(--transition-smooth);
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.btn-refresh:hover {
background: var(--text-primary);
color: var(--bg-base);
transform: scale(1.05);
}
.user-profile img {
border-radius: 50%;
width: 36px;
height: 36px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
/* Glassmorphism Cards */
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border: 1px solid var(--glass-border);
border-radius: 24px;
box-shadow: var(--glass-shadow);
padding: 1.5rem;
transition: var(--transition-smooth);
}
.glass-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
}
/* Dashboard Grid */
.dashboard-grid {
display: flex;
flex-direction: column;
gap: 2rem;
}
.metrics-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
}
.metric-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.metric-card h3 {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-secondary);
}
.metric-card .value {
font-size: 2.2rem;
font-weight: 600;
letter-spacing: -1px;
}
.trend {
font-size: 0.8rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 4px;
}
.trend.positive { color: var(--accent-green); }
.trend.negative { color: var(--accent-red); }
.trend.neutral { color: var(--text-secondary); }
/* Chart Area */
.charts-row {
display: grid;
grid-template-columns: 1fr;
}
.chart-container {
padding: 2rem;
}
.chart-header h2 {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 20px;
}
/* Animations loading state */
.loading {
opacity: 0.5;
pointer-events: none;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 0.8; }
100% { opacity: 0.5; }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.metrics-row .metric-card {
animation: fadeInUp 0.8s cubic-bezier(0.25, 1, 0.5, 1) forwards;
opacity: 0;
}
.metrics-row .metric-card:nth-child(1) { animation-delay: 0.1s; }
.metrics-row .metric-card:nth-child(2) { animation-delay: 0.2s; }
.metrics-row .metric-card:nth-child(3) { animation-delay: 0.3s; }
.metrics-row .metric-card:nth-child(4) { animation-delay: 0.4s; }
.chart-container {
animation: fadeInUp 0.8s cubic-bezier(0.25, 1, 0.5, 1) forwards;
animation-delay: 0.5s;
opacity: 0;
}
/* Tables for Data Grids */
.table-responsive {
width: 100%;
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin-top: 10px;
}
.data-table th, .data-table td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid var(--glass-border);
font-size: 0.9rem;
}
.data-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table tbody tr {
transition: background-color 0.2s;
}
.data-table tbody tr:hover {
background-color: rgba(134, 134, 139, 0.05); /* very subtle hover */
}
.text-center {
text-align: center !important;
}
/* Rank Badge styling for Repurchase / Matrix Tag */
.badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
display: inline-block;
}
.badge.primary { background: rgba(0, 113, 227, 0.1); color: var(--accent-blue); }
.badge.danger { background: rgba(255, 59, 48, 0.1); color: var(--accent-red); }
.badge.success { background: rgba(52, 199, 89, 0.1); color: var(--accent-green); }
.badge.warning { background: rgba(255, 149, 0, 0.1); color: #ff9500; }
/* ============== Ops Date Picker ============== */
.ops-date-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 1.25rem;
padding: 12px 16px;
border-radius: 12px;
background: rgba(0, 113, 227, 0.04);
border: 1px solid rgba(0, 113, 227, 0.1);
}
.ops-date-label {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.ops-date-input {
font-family: inherit;
font-size: 0.88rem;
padding: 6px 12px;
border: 1px solid var(--glass-border);
border-radius: 8px;
background: var(--glass-bg);
color: var(--text-primary);
outline: none;
transition: border-color 0.3s;
min-width: 160px;
}
.ops-date-input:focus {
border-color: var(--accent-blue);
box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.1);
}
.ops-date-hint {
font-size: 0.78rem;
color: var(--text-secondary);
}
@media (prefers-color-scheme: dark) {
.ops-date-row {
background: rgba(0, 113, 227, 0.06);
border-color: rgba(0, 113, 227, 0.15);
}
.ops-date-input {
background: rgba(28, 28, 30, 0.6);
border-color: rgba(255, 255, 255, 0.1);
}
}
/* ============== Ops Management Page ============== */
.ops-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.25rem;
margin-bottom: 2rem;
}
@media (max-width: 1200px) {
.ops-grid { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 800px) {
.ops-grid { grid-template-columns: repeat(2, 1fr); }
}
.ops-btn {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 2rem 1.25rem;
border: none;
border-radius: 20px;
background: linear-gradient(135deg, rgba(255,255,255,0.85) 0%, rgba(240,240,245,0.7) 100%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
cursor: pointer;
transition: all 0.4s cubic-bezier(0.25, 1, 0.5, 1);
color: var(--text-primary);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.04),
0 8px 24px rgba(0, 0, 0, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
overflow: hidden;
}
.ops-btn::before {
content: '';
position: absolute;
inset: 0;
border-radius: 20px;
padding: 1px;
background: linear-gradient(135deg, rgba(255,255,255,0.6), rgba(255,255,255,0.1));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.ops-btn:hover {
transform: translateY(-6px) scale(1.02);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.08),
0 20px 40px rgba(0, 113, 227, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.ops-btn:active {
transform: translateY(-2px) scale(0.98);
transition-duration: 0.1s;
}
.ops-btn-accent {
background: linear-gradient(135deg, rgba(0, 113, 227, 0.08) 0%, rgba(88, 86, 214, 0.06) 100%);
box-shadow:
0 2px 8px rgba(0, 113, 227, 0.08),
0 8px 24px rgba(0, 113, 227, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
}
.ops-btn-accent:hover {
box-shadow:
0 8px 20px rgba(0, 113, 227, 0.15),
0 20px 40px rgba(88, 86, 214, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
@media (prefers-color-scheme: dark) {
.ops-btn {
background: linear-gradient(135deg, rgba(44, 44, 46, 0.8) 0%, rgba(28, 28, 30, 0.7) 100%);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.2),
0 8px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.ops-btn::before {
background: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0.02));
}
.ops-btn:hover {
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.3),
0 20px 40px rgba(0, 113, 227, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.ops-btn-accent {
background: linear-gradient(135deg, rgba(0, 113, 227, 0.15) 0%, rgba(88, 86, 214, 0.1) 100%);
}
}
.ops-icon {
font-size: 2.5rem;
line-height: 1;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.1));
}
.ops-label {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.3px;
}
.ops-desc {
font-size: 0.78rem;
color: var(--text-secondary);
font-weight: 400;
}
/* Running state - pulsing glow */
.ops-running {
pointer-events: none;
animation: opsPulse 1.5s ease-in-out infinite;
}
@keyframes opsPulse {
0%, 100% {
opacity: 0.6;
box-shadow: 0 0 0 0 rgba(0, 113, 227, 0);
}
50% {
opacity: 1;
box-shadow: 0 0 30px rgba(0, 113, 227, 0.2);
}
}
/* Success state - green glow */
.ops-done {
background: linear-gradient(135deg, rgba(52, 199, 89, 0.1) 0%, rgba(48, 209, 88, 0.06) 100%) !important;
box-shadow: 0 0 0 2px rgba(52, 199, 89, 0.4), 0 8px 24px rgba(52, 199, 89, 0.15) !important;
}
/* Error state - red glow */
.ops-error {
background: linear-gradient(135deg, rgba(255, 59, 48, 0.1) 0%, rgba(255, 69, 58, 0.06) 100%) !important;
box-shadow: 0 0 0 2px rgba(255, 59, 48, 0.4), 0 8px 24px rgba(255, 59, 48, 0.15) !important;
}
/* Log panel — terminal aesthetic */
.ops-log-container {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--glass-border);
}
.ops-log-container h3 {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.3px;
}
.ops-log {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Menlo', 'Consolas', monospace;
font-size: 0.82rem;
line-height: 2;
background: rgba(0, 0, 0, 0.04);
border-radius: 16px;
padding: 1.25rem 1.5rem;
min-height: 80px;
max-height: 300px;
overflow-y: auto;
color: var(--text-secondary);
border: 1px solid var(--glass-border);
}
@media (prefers-color-scheme: dark) {
.ops-log {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.06);
}
}
.ops-log .log-entry {
display: block;
padding: 4px 0;
border-bottom: 1px solid rgba(134, 134, 139, 0.08);
animation: logFadeIn 0.3s ease;
}
.ops-log .log-entry:last-child {
border-bottom: none;
}
.ops-log .log-entry .log-time {
color: var(--text-secondary);
opacity: 0.6;
margin-right: 8px;
}
.ops-log .log-entry.log-success {
color: var(--accent-green);
}
.ops-log .log-entry.log-error {
color: var(--accent-red);
}
.ops-log .log-entry.log-pending {
color: var(--accent-blue);
}
@keyframes logFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}

55
server/store_debug.txt Normal file
View File

@@ -0,0 +1,55 @@
page_no=3, params={page_no=3, page_size=20}
data=
{
"warehouses" : [ {
"area" : "南山区",
"contact_name" : "宋孟广",
"address" : "广东省深圳市南山区深圳市南山区粤海街道科发路中电长城大厦乐州商场西门L1楼25号铺",
"lng" : "113.95376375961",
"contact_phone" : "+86-13428560927",
"city" : "深圳市",
"warehouse_code" : "CY101003",
"remark" : "",
"type" : 2,
"province" : "广东省",
"name" : "乐洲店",
"lat" : "22.547895080058",
"warehouse_id" : "154811012",
"status" : 0
}, {
"area" : "南山区",
"contact_name" : "宋孟广",
"address" : "广东省深圳市南山区深圳市南山区北环大道南面琼宇路10号澳特科兴科学园兴食荟B1层17号商铺厨易时代",
"lng" : "113.95415163443",
"contact_phone" : "+86-13428560927",
"city" : "深圳市",
"warehouse_code" : "CY101015",
"remark" : "",
"type" : 2,
"province" : "广东省",
"name" : "澳特科兴科学园店",
"lat" : "22.557615134587",
"warehouse_id" : "154728846",
"status" : 0
}, {
"area" : "南山区",
"contact_name" : "宋孟广",
"address" : "广东省深圳市南山区深圳市南山区南头街道马家龙社区北环大道10187南龙苑A座105厨易时代马家龙店",
"lng" : "113.93140657874",
"contact_phone" : "+86-13428560927",
"city" : "深圳市",
"warehouse_code" : "CY101008",
"remark" : "",
"type" : 2,
"province" : "广东省",
"name" : "南龙苑店",
"lat" : "22.558240201229",
"warehouse_id" : "154728759",
"status" : 0
} ],
"paginator" : {
"total_count" : 43,
"page" : 3,
"page_size" : 20
}
}

View File

@@ -0,0 +1,9 @@
========== <20><><EFBFBD><EFBFBD> ==========
URL: https://open.youzanyun.com/auth/token
Body: {"client_secret":"f7fd462479075645d38a460ceb1c2e47","grant_id":"155239704","refresh":true,"authorize_type":"silent","client_id":"f6eb734c712329dc5e"}
========== <20><>Ӧ ==========
Status: 200 OK
Body: {"success":true,"code":200,"data":{"expires":1774409167384,"scope":"item item_advanced item_super item_category item_category_advanced item_category_super trade trade_super trading_setting_advanced user user_advanced account_advanced utility utility_advanced utility_super shop_super statistics_advanced logistics coupon_advanced reward_advanced present_advanced wish_advanced peerpay_advanced gift_advanced crm_advanced fenxiao_advanced pay_advanced union_advanced wxd_app aixuedai_app oauth_advanced account_super shop weixin_advanced oauth_super shop_compatible feature_advanced pay_qrcode wsc_app store mars trade_virtual coupon pf nine bbs offline notice_center store_api_normal youren courier points_store time_limited_discount salesman seller_data_center cross_boarder niece multi_store reviews wechat_program_mars weapp_wsc open_market wm app_sdk dzt_crm union_pay pay_trans cy buyer_cart buyer_bill buyer_trade buyer_refund points storage pay_fund_channel meeting cash retail ka_customization panama beauty silently_create_account trade-delivery content_delivery trade_selffetch retail_goods retail_headquater retail_trade beauty_partner spotlight h5_resource_accelerator retail_store dsp_api app_engine oa_api retail-ump retail-peripheral retail-scrm retail-pay retail-data e_commerce_app buyer_address new_wxd_app sharing_tech_mobile seller_refund trade_invoice_platform customer electronic_invoice ebiz-owl stored_value_card gift_card beauty_staff tag beauty_item beauty_appointment beauty_member beauty_order beauty_cashier beauty_stock beauty_shop sougou cloud_api cms extension_analysis business_profile shop_advanced mei_promoter item_hotel item_standard circle selffetchcode virtualticket tags trade_advanced retail_product retail_stock retail_shop retail_supplier supplier data stored_value_card_operation retail_finance general_data level_membership project pay-customs-declaration account_import retail_permission multi_precard pay-gateway open_education_curriculum open_education_line open_education_customer open_education_student open_education_classroom open_education_class open_education_schedule open_education_order open_education_signin open_education_order open_tickets open_education_message open_education_column open_education_content open_education_broadcast rdsǨ<73><C7A8> open_beauty_service_log open_beauty_work_order open_fenxiao open_fenxiao_trade fin-consumerfin-prod <20><><EFBFBD>Է<EFBFBD><D4B7><EFBFBD>ɾ<EFBFBD><C9BE> open_education_order pay-gateway pay-login pay-ucashier retail_tag retail_tag retail_user retail_logistics retail_virtualcode retail_crossborder retail_reviews retail_reviews retail_crm retail_giftcard retail_address retail_scrm_customer retail_valuecard retail_scrm_tag retail_virtualticket retail_user retail_growth appstore_data retail_pay showcase_api MicroPageAPI open_beauty_verification open_education_coursegroup IoT iot-printer open_msg test open_robot open_clue_manage mei_open_partner material-callback enable_im crm_api transfer-portal youzan_cloud_api retail_peripheral mei_open_ka open_youzan_mcn yunying retail_finance open_isv open_bill data_center callback retail_store retail_ump retail_trade retail_SupplyChain open_trade_fenxiao pay","access_token":"a831796eae8991d7898f2e5671a12dc","authority_id":"155239704","refresh_token":"dfcd2a7a2ff6d96200b1a3257b3d538"},"message":null}
========== Token <20><>Ϣ ==========
access_token: a831796eae8991d7898f2e5671a12dc
expires: 1774409167384 (<28><><EFBFBD><EFBFBD>ʱ<EFBFBD><CAB1><EFBFBD>)

View File

@@ -0,0 +1,421 @@
===== Step 1: Get Token =====
access_token: 9fe34bcc011fcbea4cb022f0acce6a9
===== Step 2A: GET with raw URL =====
URL: https://open.youzanyun.com/api/youzan.trades.sold.get/4.0.4?access_token=9fe34bcc011fcbea4cb022f0acce6a9&start_created=2025-01-01 00:00:00&end_created=2026-03-18 23:59:59&page_no=1&page_size=5
Status: 200 OK
Body: {"trace_id":"yz7-0a34f88b-1773817700497-120320","code":106000001,"data":{"full_order_info_list":[]},"success":false,"message":"每次可搜索一年订单记录,请重新选择起止时间"}
===== Step 2B: POST with JSON body =====
URL: https://open.youzanyun.com/api/youzan.trades.sold.get/4.0.4?access_token=9fe34bcc011fcbea4cb022f0acce6a9
Body: {"page_no":1,"page_size":5,"start_created":"2025-01-01 00:00:00","end_created":"2026-03-18 23:59:59"}
Status: 200 OK
Body: {"trace_id":"yz7-0a34f88b-1773817700609-735268","code":106000001,"data":{"full_order_info_list":[]},"success":false,"message":"每次可搜索一年订单记录,请重新选择起止时间"}
===== Step 2C: GET minimal (no date filter) =====
URL: https://open.youzanyun.com/api/youzan.trades.sold.get/4.0.4?access_token=9fe34bcc011fcbea4cb022f0acce6a9&page_no=1&page_size=2
Status: 200 OK
Body:
{
"trace_id" : "yz7-0a34f88b-1773817700690-54987",
"code" : 200,
"data" : {
"full_order_info_list" : [ {
"full_order_info" : {
"child_info" : {
"child_orders" : [ ]
},
"remark_info" : {
"buyer_message" : "顾客需要餐具"
},
"address_info" : {
"self_fetch_info" : "",
"delivery_address" : "$uJsz0E3z9Ra7n01JhJiL9w5MJswLdQU+U3EkYqWrbHBlX7FlabHA3BZT/5dMp0JI$1$",
"delivery_start_time" : "2026-03-18 15:48:42",
"delivery_end_time" : "2026-03-18 15:48:42",
"delivery_postal_code" : "",
"receiver_name" : "$uhqbqg15Dn5slYolRHAzZw==$1$",
"delivery_province" : "广东省",
"delivery_city" : "深圳市",
"delivery_district" : "南山区",
"address_extra" : "{\"lon\":113.93917746985903,\"lat\":22.53359205754154}",
"receiver_tel" : "$N4JFPTBr1Pu/hOiP3oqwj+xFD/nO6mE05TDlmudyLu4=$1$"
},
"pay_info" : {
"outer_transactions" : [ ],
"post_fee" : "0.00",
"phase_payments" : [ ],
"total_fee" : "48.20",
"payment" : "20.30",
"transaction" : [ ]
},
"buyer_info" : {
"outer_user_id" : "",
"yz_open_id" : "Tpo3L1Gi617689685224894464",
"fans_type" : 0,
"fans_id" : 0,
"fans_nickname" : ""
},
"orders" : [ {
"is_cross_border" : "",
"outer_item_id" : "",
"item_type" : 0,
"discount_price" : "0.10",
"num" : 1,
"unified_sku_id" : "",
"oid" : "3133294190046609430",
"title" : "【福利】太太笑酱油(酿造酱油)1包仅按需出货",
"fenxiao_payment" : "0.00",
"item_no" : "",
"buyer_messages" : "",
"root_sku_id" : "",
"is_present" : false,
"cross_border_trade_mode" : "",
"unified_item_id" : "",
"price" : "3.00",
"sub_order_no" : "",
"total_fee" : "0.10",
"fenxiao_price" : "0.00",
"alias" : "mockItem",
"payment" : "-0.09",
"item_barcode" : "",
"is_pre_sale" : "",
"outer_sku_id" : "",
"sku_unique_code" : "",
"goods_url" : "https://h5.youzan.com/v2/showcase/goods?alias=mockItem",
"customs_code" : "",
"item_tags" : { },
"item_id" : 1,
"weight" : "0",
"sku_id" : 0,
"sku_properties_name" : "",
"pic_path" : "http://p0.meituan.net/wmproduct/e6760396d7b0d4fe460db844b179ed0581788.jpg",
"shop_org_id" : "",
"is_combo" : false,
"pre_sale_type" : "",
"points_price" : "",
"sku_no" : "",
"root_item_id" : "1",
"origin_place_code" : "",
"sku_barcode" : ""
}, {
"is_cross_border" : "",
"outer_item_id" : "P260202734126346",
"item_type" : 0,
"discount_price" : "31.90",
"num" : 1,
"unified_sku_id" : "",
"oid" : "3133294190046609431",
"title" : "照烧猪扒饭+鸡汤4选1",
"fenxiao_payment" : "0.00",
"item_no" : "",
"buyer_messages" : "",
"root_sku_id" : "",
"is_present" : false,
"cross_border_trade_mode" : "",
"unified_item_id" : "",
"price" : "41.90",
"sub_order_no" : "",
"total_fee" : "31.90",
"fenxiao_price" : "0.00",
"alias" : "27c0up9w6cytfao",
"payment" : "19.09",
"item_barcode" : "P260202734126346",
"is_pre_sale" : "",
"outer_sku_id" : "P260202734126346",
"sku_unique_code" : "",
"goods_url" : "https://h5.youzan.com/v2/showcase/goods?alias=27c0up9w6cytfao",
"customs_code" : "",
"item_tags" : { },
"item_id" : 5506399661,
"weight" : "",
"sku_id" : 0,
"sku_properties_name" : "",
"pic_path" : "https://img.yzcdn.cn/upload_files/2026/02/02/Ftis04198qzLhUjbgfz4XYh9V2Pr.png",
"shop_org_id" : "",
"is_combo" : true,
"pre_sale_type" : "",
"points_price" : "",
"sku_no" : "",
"root_item_id" : "5506394377",
"origin_place_code" : "",
"sku_barcode" : "P260202734126346"
}, {
"is_cross_border" : "",
"outer_item_id" : "",
"item_type" : 0,
"discount_price" : "0.00",
"num" : 1,
"unified_sku_id" : "",
"oid" : "3133294190046609432",
"title" : "需要餐具(多点不送)",
"fenxiao_payment" : "0.00",
"item_no" : "",
"buyer_messages" : "",
"root_sku_id" : "",
"is_present" : false,
"cross_border_trade_mode" : "",
"unified_item_id" : "",
"price" : "0.00",
"sub_order_no" : "",
"total_fee" : "0.00",
"fenxiao_price" : "0.00",
"alias" : "mockItem",
"payment" : "0.00",
"item_barcode" : "",
"is_pre_sale" : "",
"outer_sku_id" : "",
"sku_unique_code" : "",
"goods_url" : "https://h5.youzan.com/v2/showcase/goods?alias=mockItem",
"customs_code" : "",
"item_tags" : { },
"item_id" : 1,
"weight" : "0",
"sku_id" : 0,
"sku_properties_name" : "",
"pic_path" : "http://p0.meituan.net/wmproduct/a54ec40aceb9940eb4742cd6e7280e0b20373.png",
"shop_org_id" : "",
"is_combo" : false,
"pre_sale_type" : "",
"points_price" : "",
"sku_no" : "",
"root_item_id" : "1",
"origin_place_code" : "",
"sku_barcode" : ""
} ],
"out_order_info" : {
"income" : "15.88",
"out_order_promotions" : [ ],
"out_order_agent_service_fee" : "5.12",
"out_order_container_fee" : "1.30",
"out_order_activity_fee_agent_part" : "0.00",
"out_order_no" : "3302041381960972306",
"out_order_activity_fee_shop_part" : "0.00"
},
"source_info" : {
"is_offline_order" : false,
"book_key" : "",
"biz_source" : "trade-oom",
"source" : {
"platform" : "other",
"wx_entrance" : "direct_buy"
},
"order_mark" : "meituan"
},
"order_info" : {
"consign_time" : "",
"order_extra" : {
"is_from_cart" : "false",
"is_member" : "false",
"serial_no" : "W00040"
},
"expired_time" : "",
"shop_display_no" : "CY101022",
"type" : 0,
"tid" : "E20260318150643020806217",
"node_kdt_id" : 155103507,
"update_time" : "2026-03-18 15:07:00",
"pay_type_str" : "OUTSIDE_PAYMENT",
"pay_type" : 203,
"team_type" : 7,
"created" : "2026-03-18 15:06:27",
"offline_id" : 155103507,
"status_str" : "待发货",
"success_time" : "",
"shop_name" : "恒大天璟店",
"confirm_time" : "2026-03-18 15:06:45",
"pay_time" : "2026-03-18 15:06:44",
"is_retail_order" : true,
"refund_state" : 0,
"root_kdt_id" : 154234003,
"close_type" : 0,
"status" : "WAIT_SELLER_SEND_GOODS",
"express_type" : 2,
"order_tags" : {
"is_secured_transactions" : true,
"is_payed" : true
}
}
}
}, {
"full_order_info" : {
"child_info" : {
"child_orders" : [ ]
},
"remark_info" : {
"buyer_message" : "多加点米多加点料汁,加点辣椒,谢谢 顾客需要餐具"
},
"address_info" : {
"self_fetch_info" : "",
"delivery_address" : "$vsTrZo+w5XbVuPAoajeIlyxo/rlCjqbT1Qaq0qkR72c=$1$",
"delivery_start_time" : "2026-03-18 16:00:00",
"delivery_end_time" : "2026-03-18 16:30:00",
"delivery_postal_code" : "",
"receiver_name" : "$DTzRvjcsw9GzZoSZExOTOw==$1$",
"delivery_province" : "广东省",
"delivery_city" : "深圳市",
"delivery_district" : "南山区",
"address_extra" : "{\"lon\":113.99256582138798,\"lat\":22.55398747058354}",
"receiver_tel" : "$d78vgn1Ip0UilVhcqVoyW58IMCbn84Z2y6lJ64doUdw=$1$"
},
"pay_info" : {
"outer_transactions" : [ ],
"post_fee" : "0.00",
"phase_payments" : [ ],
"total_fee" : "37.40",
"payment" : "20.80",
"transaction" : [ ]
},
"buyer_info" : {
"outer_user_id" : "",
"yz_open_id" : "mwOmwCoG617117584948465664",
"fans_type" : 0,
"fans_id" : 0,
"fans_nickname" : ""
},
"orders" : [ {
"is_cross_border" : "",
"outer_item_id" : "",
"item_type" : 0,
"discount_price" : "0.00",
"num" : 1,
"unified_sku_id" : "",
"oid" : "3133294121375498391",
"title" : "需要餐具(多点不送)",
"fenxiao_payment" : "0.00",
"item_no" : "",
"buyer_messages" : "",
"root_sku_id" : "",
"is_present" : false,
"cross_border_trade_mode" : "",
"unified_item_id" : "",
"price" : "0.00",
"sub_order_no" : "",
"total_fee" : "0.00",
"fenxiao_price" : "0.00",
"alias" : "mockItem",
"payment" : "0.00",
"item_barcode" : "",
"is_pre_sale" : "",
"outer_sku_id" : "",
"sku_unique_code" : "",
"goods_url" : "https://h5.youzan.com/v2/showcase/goods?alias=mockItem",
"customs_code" : "",
"item_tags" : { },
"item_id" : 1,
"weight" : "0",
"sku_id" : 0,
"sku_properties_name" : "",
"pic_path" : "http://p0.meituan.net/wmproduct/a54ec40aceb9940eb4742cd6e7280e0b20373.png",
"shop_org_id" : "",
"is_combo" : false,
"pre_sale_type" : "",
"points_price" : "",
"sku_no" : "",
"root_item_id" : "1",
"origin_place_code" : "",
"sku_barcode" : ""
}, {
"is_cross_border" : "",
"outer_item_id" : "80700311",
"item_type" : 0,
"discount_price" : "25.90",
"num" : 1,
"unified_sku_id" : "",
"oid" : "3133294121375498392",
"title" : "【爆】照烧猪扒饭",
"fenxiao_payment" : "0.00",
"item_no" : "",
"buyer_messages" : "",
"root_sku_id" : "",
"is_present" : false,
"cross_border_trade_mode" : "",
"unified_item_id" : "",
"price" : "33.90",
"sub_order_no" : "",
"total_fee" : "25.90",
"fenxiao_price" : "0.00",
"alias" : "35zu9dvb231s43a",
"payment" : "19.90",
"item_barcode" : "80700311",
"is_pre_sale" : "",
"outer_sku_id" : "80700311",
"sku_unique_code" : "",
"goods_url" : "https://h5.youzan.com/v2/showcase/goods?alias=35zu9dvb231s43a",
"customs_code" : "",
"item_tags" : { },
"item_id" : 5319189739,
"weight" : "",
"sku_id" : 0,
"sku_properties_name" : "",
"pic_path" : "https://img.yzcdn.cn/upload_files/2025/12/27/FrSZ5GQ5kWXxbhWWs1QN_CpP8SqT.jpg",
"shop_org_id" : "",
"is_combo" : false,
"pre_sale_type" : "",
"points_price" : "",
"sku_no" : "",
"root_item_id" : "5298093866",
"origin_place_code" : "",
"sku_barcode" : "80700311"
} ],
"out_order_info" : {
"income" : "12.68",
"out_order_promotions" : [ ],
"out_order_agent_service_fee" : "5.12",
"out_order_container_fee" : "0.90",
"out_order_activity_fee_agent_part" : "0.00",
"out_order_no" : "3302041382843231566",
"out_order_activity_fee_shop_part" : "0.00"
},
"source_info" : {
"is_offline_order" : false,
"book_key" : "",
"biz_source" : "trade-oom",
"source" : {
"platform" : "other",
"wx_entrance" : "direct_buy"
},
"order_mark" : "meituan"
},
"order_info" : {
"consign_time" : "",
"order_extra" : {
"is_from_cart" : "false",
"is_member" : "false",
"serial_no" : "W00061"
},
"expired_time" : "",
"shop_display_no" : "CY101028",
"type" : 0,
"tid" : "E20260318150611030100073",
"node_kdt_id" : 155035540,
"update_time" : "2026-03-18 15:06:27",
"pay_type_str" : "OUTSIDE_PAYMENT",
"pay_type" : 203,
"team_type" : 7,
"created" : "2026-03-18 15:06:09",
"offline_id" : 155035540,
"status_str" : "待发货",
"success_time" : "",
"shop_name" : "香年广场",
"confirm_time" : "2026-03-18 15:06:13",
"pay_time" : "2026-03-18 15:06:12",
"is_retail_order" : true,
"refund_state" : 0,
"root_kdt_id" : 154234003,
"close_type" : 0,
"status" : "WAIT_SELLER_SEND_GOODS",
"express_type" : 2,
"order_tags" : {
"is_secured_transactions" : true,
"is_payed" : true
}
}
}
} ],
"total_results" : 45356
},
"success" : true,
"message" : "successful"
}