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

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

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# 1. 忽略根目录所有文件和隐藏文件夹
/*
.*
# 2. 豁免(不忽略)指定的根目录文件和文件夹
!/.gitignore
!/README.md
!/server/
!/sql/
# 3. 针对 docs 目录:先豁免目录本身,忽略里面所有内容,然后再单一豁免目标子目录
!/docs/
/docs/*
!/docs/餐饮零售数据中台/
# 4. 单独针对 server 项目,忽略常见的编译和缓存临时文件
/server/target/
/server/.mvn/
/server/*.iml
/server/.idea/

221
README.md Normal file
View File

@@ -0,0 +1,221 @@
# 餐饮零售数据中台 (DataHub)
> 基于有赞云 API 的连锁餐饮数据中台系统,实现订单/商品/客户/库存数据的 T+1 自动同步,并提供商品洞察、会员 RFM 分层、购物篮挖掘、智能订货建议等 BI 分析能力。
---
## 项目结构
```
AIMachineSystem/
├── server/ # Spring Boot 后端服务
│ ├── src/main/java/ # Java 源代码
│ ├── src/main/resources/application.yml # 应用配置
│ └── pom.xml # Maven 依赖管理
├── dashboard-ui/ # 前端 BI 大盘 (纯 HTML/CSS/JS)
│ ├── index.html # 主页面
│ ├── styles.css # 样式 (毛玻璃/苹果设计风格)
│ └── app.js # 数据交互 (ECharts 图表)
├── sql/ # 数据库 DDL & 补丁
│ ├── youzan_tplus1_ddl.sql # 全量建表 (一键建库)
│ └── phase4_alter_outer_item_id.sql # 增量补丁
├── docs/ # 产品需求文档 & 技术设计文档
└── prototype/ # 原型设计
```
---
## 技术栈
| 类别 | 技术 |
|------|------|
| 后端框架 | Java 17 + Spring Boot 3.2.x |
| ORM | MyBatis-Plus 3.5.x |
| 数据库 | MySQL 8.0 |
| 数据源 | 有赞云开放平台 API |
| 前端 | HTML5 + CSS3 + JavaScript + ECharts |
| 构建工具 | Maven |
---
## 安装部署
### 1. 环境要求
- **JDK 17+**
- **Maven 3.8+**
- **MySQL 8.0+**
- 有赞云自用型应用(需要 `client_id``client_secret``grant_id`
### 2. 数据库初始化
```sql
-- 1. 创建数据库
CREATE DATABASE youzandatahub DEFAULT CHARSET utf8mb4;
-- 2. 执行全量建表脚本
SOURCE sql/youzan_tplus1_ddl.sql;
```
### 3. 修改配置
编辑 `server/src/main/resources/application.yml`
```yaml
spring:
datasource:
url: jdbc:mysql://你的IP:3306/youzandatahub?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: 你的用户名
password: 你的密码
youzan:
api:
base-url: https://open.youzanyun.com
client-id: 你的client_id
client-secret: 你的client_secret
kdt-id: 你的kdt_id
```
### 4. 编译 & 启动
```bash
cd server
mvn clean compile
mvn spring-boot:run
```
服务启动后监听 `http://localhost:8080`
### 5. 前端访问
直接用浏览器打开 `dashboard-ui/index.html` 即可,无需额外构建。
> 前端默认连接 `http://localhost:8080` 后端接口,如需修改请编辑 `app.js` 中的 `API_BASE` 变量。
---
## 定时任务 (自动执行)
系统启动后,以下任务每日自动执行,**无需人工干预**
| 执行时间 | 任务名称 | Job 类 | 说明 |
|---------|---------|--------|------|
| 02:00 | 订单同步 | `TradeSyncJob` | 拉取前一天全量订单明细写入 `dwd_trade_order_detail` |
| 02:30 | 商品同步 | `ItemCustomerSyncJob` | 全量同步有赞商品目录到 `dim_item_sku` |
| 02:45 | 客户聚合 | `ItemCustomerSyncJob` | 从订单表聚合客户画像到 `dim_customer_info` |
| 03:00 | 门店同步 | `Phase3SyncJob` | 同步门店/网点信息到 `dim_offline_store` |
| 03:10 | 退款同步 | `Phase3SyncJob` | 拉取前一天退款单到 `dwd_trade_refund_detail` |
| 03:20 | 库存快照 | `Phase3SyncJob` | 采集各门店当日库存到 `dim_inventory_snapshot` |
| 04:00 | 洞察计算 | `InsightComputeJob` | 全域数据市集聚合(详见下方) |
### 04:00 洞察计算明细
| 步骤 | 计算内容 | 写入表 |
|------|---------|--------|
| 1 | 商品日销售趋势 | `adm_item_sales_trend` |
| 2 | 购物篮关联规则 (Apriori Top20) | `adm_item_basket` |
| 3 | 商品30天复购率 & 波士顿矩阵 | `adm_item_repurchase` |
| 4 | 会员 RFM 评分 & 客群分层 | `adm_customer_rfm` |
| 5 | 会员特征标签画像 | `adm_customer_tags` |
| 6 | 智能订货建议 (14天流速) | 实时计算,不落表 |
---
## 手动运行指令
### HTTP 接口 (浏览器直接访问)
| 接口地址 | 用途 |
|---------|------|
| `GET /api/insight/compute-all` | 手动触发全量洞察计算(等同于 04:00 的定时任务) |
| `GET /api/admin/backfill/january-to-now` | 补全今年1月至今全量历史订单+退款+客户(后台异步执行,按天拉取) |
### BI 查询接口 (前端调用)
| 接口地址 | 返回内容 |
|---------|---------|
| `GET /api/dashboard/overview` | 过去14天大盘概览GMV、订单数、退款额、客单价 |
| `GET /api/dashboard/trend` | 过去14天日维销售趋势 |
| `GET /api/product/repurchase` | 商品30天复购率 & 波士顿矩阵打标 |
| `GET /api/product/basket` | 购物篮关联 Top20 |
| `GET /api/product/advice` | 智能订货预测单基于14天日均流速 |
| `GET /api/customer/rfm/distribution` | RFM 客群金字塔分布 |
| `GET /api/customer/tags` | 会员标签统计 |
### 诊断测试脚本 (独立 main 方法)
`server/` 目录下执行:
```powershell
# 有赞 Token 连通性测试
mvn -q compile exec:java `-Dexec.mainClass="com.chuyishidai.datahub.YouzanTokenTest"
# 订单数据诊断 (分页、总量对比)
mvn -q compile exec:java `-Dexec.mainClass="com.chuyishidai.datahub.YouzanOrderDiagTest"
# 商品 item_id 比对诊断
mvn -q compile exec:java `-Dexec.mainClass="com.chuyishidai.datahub.ItemIdDiagTest"
# 退款数据诊断
mvn -q compile exec:java `-Dexec.mainClass="com.chuyishidai.datahub.RefundDiagTest"
```
> **注意**: 在 PowerShell 中 `-D` 前需加反引号 `` ` `` 转义。
---
## 数据分层架构
```
有赞云 API ──► DWD 明细层 (事实表) ──► DIM 维度层 ──► ADM 数据市集层 (洞察)
REST API
前端 BI 大盘
```
| 层级 | 前缀 | 表 | 说明 |
|-----|------|------|------|
| 明细层 | `dwd_` | `dwd_trade_order_detail` | 交易订单明细 |
| | | `dwd_trade_refund_detail` | 退款明细 |
| | | `dwd_inventory_flow_di` | 库存流水 |
| | | `dwd_coupon_flow` | 优惠券流水 |
| 维度层 | `dim_` | `dim_item_sku` | 商品维度 |
| | | `dim_customer_info` | 客户维度 (OneID) |
| | | `dim_offline_store` | 门店维度 |
| | | `dim_inventory_snapshot` | 库存快照 |
| 市集层 | `adm_` | `adm_item_sales_trend` | 商品日销趋势 |
| | | `adm_item_basket` | 购物篮关联 |
| | | `adm_item_repurchase` | 复购率 & 波士顿矩阵 |
| | | `adm_customer_rfm` | RFM 评分 |
| | | `adm_customer_tags` | 客户标签 |
### 重要设计决策
- **商品聚合维度**: 有赞连锁模式下同一商品在不同门店有不同 `item_id`,系统统一使用 `outer_item_id`(商家编码)作为跨门店商品唯一标识。
- **数据同步策略**: 采用 `INSERT ... ON DUPLICATE KEY UPDATE` (Upsert) 保证幂等性。
- **快照表刷新**: `adm_` 前缀的快照型表(除 `adm_item_sales_trend` 外)每次计算前先 `DELETE``INSERT`,确保全量覆盖。
- **餐具过滤**: 购物篮和复购率计算自动过滤 `title LIKE '%餐具%'` 的必选附加品。
---
## 配置参数说明
`application.yml` 中可调整的关键参数:
```yaml
sync:
trade:
cron: "0 0 2 * * ?" # 订单同步 cron 表达式
page-size: 100 # 每页拉取条数
overlap-minutes: 30 # 时间窗口重叠 (防止遗漏边界订单)
```
其他定时任务 cron 通过系统属性覆盖:
- `sync.item.cron` — 商品同步 (默认 02:30)
- `sync.customer.cron` — 客户同步 (默认 02:45)
- `sync.store.cron` — 门店同步 (默认 03:00)
- `sync.refund.cron` — 退款同步 (默认 03:10)
- `sync.inventory.cron` — 库存快照 (默认 03:20)

View File

@@ -0,0 +1,67 @@
# 餐饮零售数据中台 V2.0 业务蓝图 (聚焦数据洞察)
**文档版本:** V2.0 (边界收拢版)
**业务模式:** 纯分销与零售(中央工厂拿货 + 门店加热外带/外卖 + 线上冷链零售)
**核心愿景:** 打造基于有赞生态的“洞察大脑”,通过商品数据优化采购与流转,通过客户数据实现高频复购与场景跨越。
---
## 一、 商品洞察网络 (Product Insights卖什么怎么订)
**业务目标:** 告别经验主义备货,实现高动销、低库存、零过期损耗。
### 1. 全渠道 SKU 动销与生命周期洞察
* **全域销量折算模型:** 将“门店加热售出的熟食份数”与“线上售出的冷链包装份数”统一映射为底层 SKU 消耗总量,呈现单一预制菜的全盘真实热度。
* **双栖波士顿矩阵 (BCG Matrix)**
* **热食爆款:** 门店端高频复购,出餐快,作为“引流款”持续吸客。
* **冷链爆款:** 线上端高客单囤货,作为“利润款”重点推流。
* **双栖滞销款:** 线上线下双重遇冷,系统输出《淘汰建议清单》,直接从向央厨的采购目录中剔除。
### 2. 智能订货与库存健康度洞察
* **基于流速的订货预测:** 结合有赞过去 7-14 天的日均消耗流速(动销率)、周末/节假日权重,自动计算单店合理的《次日/周采货建议单》,防止门店盲目多订或少订。
* **效期流转预警洞察:** 追踪从总仓拿货的各批次在库时间。当预制菜库存可用天数超出安全阈值,或保质期剩余不足 30% 时,系统自动标记为“临期资产”,并触发门店在有赞外卖或小程序挂出“今日特惠”以加速出清。
### 3. 购物篮关联分析 (Market Basket Analysis)
* **连带率洞察:** 分析外卖/外带场景下的高频组合(例如:点红烧肉加热快餐的用户,有多大比例会顺手带一瓶冷萃茶)。通过洞察支持有赞前端的“套餐组合”和“猜你喜欢”策略,提升客单价。
---
## 二、 客户洞察引擎 (Customer Insights谁在买怎么复购)
**业务目标:** 将即买即走的“外卖快餐客”,转化为高客单价、高留存的“家庭冷链囤货客”。
### 1. O2O 全域 OneID 画像洞察
* **身份跨端融合:** 抓取有赞的外卖订单、自提订单与线上商城订单,以“手机号/微信生态 ID”为主键将同一用户的“外带热食记录”与“网购冷链记录”无缝拼接。
* **场景跨越追踪:** 清晰刻画用户流转轨迹(例如:某用户连续三周在 A 门店买工作日加热午餐,上周末在线上商城首次下单了同款半成品)。
### 2. RFM 客户价值分层洞察
系统利用 T+1 定时任务,每日计算用户的 R(最近消费)、F(消费频次)、M(消费金额),并自动打标:
* **核心高优客:** 双渠道高频购买(既吃外卖又买冷链),系统可自动推送“月度订阅制套餐”。
* **单栖高频客:** 仅在外卖端高频,中台可自动触发有赞短信/模板消息,推送“同款冷链预制包试吃券”,引导向零售场景跨越。
* **流失预警客:** 曾经高频但近 30 天未下单,系统生成召回名单,匹配高额无门槛券。
### 3. 同期群留存洞察 (Cohort Analysis)
* **拉新质量评估:** 监控每个自然月(或特定营销活动)获取的新客户,在后续 1 个月、3 个月、6 个月的留存率与复购率变化。
* **转化漏斗洞察:** 追踪“外卖包裹内塞入冷链网购优惠券”的实际核销链路,计算真实的场景转化率(热食 -> 零售转化率)。
---
## 三、 数据流转与技术支撑底座 (Tech Foundation)
中台不干涉前端业务履约,专注做数据的“抽水机”与“计算器”:
* **数据采集源 (有赞云 API)**
* `交易域`:实时获取订单流水、支付明细、购买商品列表。
* `商品域`同步全量商品库、SKU 信息。
* `客户域`:拉取会员基本信息、积分资产、行为轨迹。
* **处理架构架构建议:**
* 采用 Spring Boot 编写高频定时任务,解决全量历史数据的拉取与状态对账。
* 引入 Kafka 缓冲订单洪峰,使用 Flink 处理实时动销流速与实时规则匹配。
* 业务数据沉淀至 MySQL利用 Redis 保障全局 Token 有效期与高频接口限流。

View File

@@ -0,0 +1,96 @@
# 餐饮零售数据中台 V2.0 - 产品需求文档 (PRD)
**文档版本:** V2.0
**产品定位:** 聚焦餐饮零售化(中央工厂分销 + 门店加热外带 + 线上冷链)的数据洞察与智能营销大脑。
**核心价值:** 告别经验主义,用数据指导“怎么拿货、怎么卖(商品洞察)”和“谁在买、怎么让他多买(客户洞察)”。
---
## 一、 业务背景与产品目标
在“纯拿货 + 加热外带/线上零售”的新型餐饮模型下,业务的核心痛点已从传统的后厨管理转移至**供应链终端流转效率**与**全渠道客流变现**。
本系统的建设目标是打通有赞云的交易、商品、会员底层数据,构建两大洞察网络,实现:
1. **降本增效:** 精准预测订货量,降低门店预制菜过期损耗。
2. **营收增长:** 识别高优人群,通过 O2O 场景交叉营销提升客单价与复购率。
---
## 二、 目标角色与使用场景
1. **运营操盘手 / 决策层**
* **使用诉求:** 洞察全盘销售趋势与客户贡献度;发现商品关联规律以制定套餐;圈选特定人群包推送到有赞进行精准发券。
2. **直营店长 / 执行层**
* **使用诉求:** 获取每日系统生成的《智能拿货建议单》;查看门店商品异常降速预警并执行促销打折指令。
---
## 三、 核心功能模块定义
### 3.1 商品洞察网络 (Product Insights)
*聚焦解决“货与场”的匹配,指导门店科学订货与上架策略。*
#### 3.1.1 销售趋势分析 (Sales Trend Analysis)
* **业务逻辑:** 监控单一大单品(如:麻辣小龙虾预制包)在不同时间周期(日、周、月)的全渠道单量起伏。
* **功能点:**
* **生命周期折线图:** 剔除异常波动(如极端天气),拟合真实动销趋势线。
* **智能订货预测模型:** 结合过去 14 天平滑流速与周末权重,为门店生成《次日/周最优拿货单》,防止爆款断货或冷门款积压。
#### 3.1.2 关联销售分析 (Market Basket Analysis)
* **业务逻辑:** 基于经典购物篮算法(如 Apriori 算法),挖掘外卖/线上购物车中的连带规律。
* **功能点:**
* **高频组合发现:** 输出如“买冷链酸菜鱼的用户65% 关联购买了速冻糍粑”的洞察结果。
* **策略支撑:** 指导运营人员在有赞后台进行“满减套餐”打包或在下单页配置“猜你喜欢”推荐。
#### 3.1.3 商品复购分析 (Item Repurchase Analysis)
* **业务逻辑:** 评估“这道菜到底好不好吃/值不值”的客观指标。
* **功能点:**
* **单品粘性测算:** 计算某 SKU 的“30天内二次购买率”。
* **波士顿矩阵打标:** 首单高但复购极低的打上“需优化/淘汰”标签;复购率持续居高的打上“核心引流款”标签,重点保障进货量。
---
### 3.2 客户洞察引擎 (Customer Insights)
*聚焦解决“人与钱”的深度挖掘,将外卖流量沉淀为私域资产。*
#### 3.2.1 专属人群特征画像 (Audience Profiling)
* **业务逻辑:** 打破线上商城与线下外卖的数据隔离,构建 OneID 统一视图。
* **功能点:**
* **跨端身份映射:** 以手机号/微信 UnionID 为主键,缝合该用户的全渠道轨迹。
* **立体标签库:** 自动打标,如“工作日外带客”、“微辣偏好”、“高客单冷链囤货客”、“周五高频活跃”等。
#### 3.2.2 专属分层人群包 (Tiered Audience Segments)
* **业务逻辑:** 业务人员基于特征标签,自由组合圈选人群,用于精准触达。
* **功能点:**
* **动态分群圈选:** 例如交集提取“近 30 天门店外带 > 3 次” 且 “历史线上商城消费 = 0” 的特定人群。
* **自动化触达:** 将圈选好的人群包直接对接有赞发券 API定向推送“同款冷链预制菜 7 折体验券”,完成 O2O 场景折叠。
#### 3.2.3 RFM 与销售贡献分析 (RFM & Sales Contribution)
* **业务逻辑:** 基于交易流水,动态计算每位客户的 R (最近消费)、F (消费频次)、M (消费金额)。
* **功能点:**
* **二八定律验证面板:** 直观展示前 20% 的超级用户(如订阅制月卡用户)贡献了多少大盘利润。
* **价值分层自动化应对:**
* *高价值客:* 专属客服、新品试吃权。
* *沉睡预警客:* 系统自动触发大额无门槛召回券。
---
## 四、 最小可行性架构与数据采集底座 (MVP Architecture)
为控制初创期研发与运维成本V1.0 采用轻量级技术栈:
1. **核心框架:** 采用 Java (Spring Boot) 开发独立的数据中台微服务,配合 MyBatis 处理复杂 SQL 与映射。
2. **数据接入 (有赞云 API)**
* **异步离线拉取:** 依赖 XXL-JOB 或 Spring Task每小时/每天凌晨定时分页拉取 `youzan.trade` (订单历史) 和 `youzan.item` (全量商品)。
* **轻量级实时接收:** 接收有赞 Webhook (如订单支付成功),放弃中间件,直接使用 Spring Boot 的 `@Async` 异步线程池或 Redis Stream 进行轻量级缓冲,避免阻塞有赞回调。
3. **数据存储:**
* **MySQL** 作为核心数仓,存放 OneID 宽表、清洗后的订单流水及 RFM 聚合结果。
* **Redis** 刚需组件。处理有赞全局 `access_token` 的高可用缓存、API 接口限流控制,以及轻量级的短平快队列。

View File

@@ -0,0 +1,133 @@
# 餐饮零售数据中台 V2.0 - 有赞云对接设计文档
**文档版本:** V1.0
**关联文档:**
- 《1.业务蓝图 (聚焦数据洞察)》
- 《2.产品需求文档 (PRD)》
**对接目标:**
实现基于有赞云的交易、商品、客户数据的全面打通,为中台的“商品洞察网络”与“客户洞察引擎”提供稳健的数据底座及自动化营销触达能力。
---
## 一、 总体对接架构与鉴权体系
为了实现轻量级 MVP 架构并保障数据安全流转,系统与有赞云的交互架构设计如下:
### 1.1 应用类型与授权
* **应用类型:** 自用型应用(针对商家自有系统对接)。
* **鉴权方式:** OAuth 2.0 (Client ID + Client Secret)。
* **Token 管理:**
* 中台定期调用 `oauth/token` 接口获取 `access_token`
* `access_token``refresh_token` 统一持久化至 **Redis**,有效期以接口返回的 `expires_in` 为准。
* 设计定时任务在 `expires_in` 的 80% 时点刷新 Token防止接口调用中断。
### 1.2 数据交互引擎 (纯 T+1 离线拉取)
为保持系统架构的轻量化与高可用,本期 MVP 剥离复杂的 Webhook 实时消息订阅,采用**纯主动拉取 (Pull)** 模式:
* **定时任务拉取:** 通过设定特定频率的定时任务(如 T+1 凌晨批处理或高频率的小时级级拉取),分页调用有赞 API。
* **适用场景:** 用于历史数据初始化、T+1 夜间数据对账合并、全量/增量商品库及客户池信息的同步。
* **优势:** 去除对 Kafka/Redis Stream 等高并发消息件的重度依赖,大幅降低运维成本和幂等性处理的复杂度。
---
## 二、 核心业务域对接设计(能力包驱动)
本章节以“能力包名称”为准,不使用未验证的具体接口方法名。
字段与接口明细以《4.有赞云接口对应表》为准,并在控制台“查看”页确认后补齐。
### 2.1 交易与售后域 - 支撑动销分析、全域RFM、净额口径
这是中台最核心的数据流水来源。
* **依赖能力包(已获得):**
* 订单同步
* 售后单同步
* 售后单审核处理
* 网店仅退款 / 网店退货退款
* 门店仅退款基于有赞POS / 门店退货退款基于有赞POS
* **核心数据提取与应用(字段待确认):**
* 订单主表与明细订单ID、支付时间、实付金额、订单明细SKU/数量/价格
* 售后/退款单:退款金额、退款时间、退款状态
* 门店/网点归属字段:用于门店维度对比
* **口径要求:**
* 净销售额 = 支付成功订单金额 - 已完成退款金额
* 退款单必须与订单明细关联,避免动销失真
### 2.2 商品与SKU域 - 支撑生命周期流转、智能订货
实现门店加热款与线上冷链款的数据统一。
* **依赖能力包(已获得):**
* 商品查询 / 商品更新 / 上下架商品
* 商品分组 / 商品标准
* 门店商品管理 / 店铺商品上下架到网点 / 多网点商品关联配送方式
* **核心数据提取与应用(字段待确认):**
* 商品ID、SKU ID、名称、类目、规格、状态、售价
* 门店/网点商品状态与配送方式
* **口径建议:**
* 使用“外部编码/自定义字段”建立热食与冷链SKU统一映射
* 若无统一编码,需人工维护 SKU 映射表
### 2.3 客户与粉丝域 - 支撑 OneID 画像与人群分层
解决“谁在买”,并为精准触达提供人群基础。
* **依赖能力包(已获得):**
* 店铺客户信息同步
* 店铺客户标签管理
* 会员等级打通 / 会员权益卡打通
* 微信粉丝关联有赞用户 / 微信粉丝查询 / 微信粉丝标签管理
* **核心数据提取与应用(字段待确认):**
* 客户ID/buyer_id、手机号、注册时间、来源
* 标签/等级/权益卡信息
* 粉丝ID、open_id/union_id、关联 buyer_id
* **OneID 策略:**
* 以手机号为主键,必须输出“识别率”指标(手机号覆盖率)
* 微信粉丝仅作为补充画像渠道,不做强制跨端合并
### 2.4 库存与进销存域 - 支撑动销、损耗与补货
商品洞察闭环的关键依赖域。
* **依赖能力包(已获得):**
* ERP全渠道库存同步
* 多网点有赞库存同步线下 / 多网点线下库存同步有赞
* 网店库存调整 / 库存盘点 / 库存采购 / 采购退货 / 库存调拨
* 连锁库存同步(总部管理/网店独立管理)
* **核心数据提取与应用(字段待确认):**
* 库存数量、库存变动流水、采购/调拨/盘点单据
* **口径建议:**
* 以库存流水 + 订单明细计算真实动销与损耗
### 2.5 营销与评价域 - 支撑活动效果与触达闭环
* **依赖能力包(已获得):**
* 营销活动查询
* 优惠券管理(活动、发放、核销)
* 电子卡券核销
* 限时折扣 / 自定义会员价
* 评价管理
* **核心数据提取与应用(字段待确认):**
* 活动ID、活动类型、开始/结束时间、核销记录
* **触达闭环:**
* 可基于人群包输出进行自动发券(具体接口名待控制台确认)
---
## 三、 定时任务与数据流转编排 (MVP 架构)
### 3.1 T+1 离线批处理 (建议触发时间:凌晨 02:00)
1. **订单与售后流水增量拉取:** 基于“订单同步 + 售后/退款相关能力包”拉取前一日增量。依靠订单ID与更新时间在中台 MySQL 执行 `UPSERT`,确保数据最终一致性。
2. **RFM 批量重算:** 基于清洗后的 MySQL 全局交易宽表,重新计算所有涉及变动用户的 R、F、M 值,并重新刷新其客户生命周期标签(如:高优客 -> 沉睡客)。
3. **连带率与购物篮挖掘:** 执行 Apriori / FP-Growth 算法或离线 SQL 统计过去一周的外卖订单 `orders` 组合频率,更新高频搭配推荐库。
4. **智能订货预测:** 统计过去 14 天各统一映射后的物理单品日均消耗量,结合周末权重因子,生成门店视角的《次日最优拿货建议单》。
### 3.2 灵活的近线调度 (可选)
对于店长极其关心的“日内单品动销趋势”(例如想看今天中午小龙虾卖了多少),可在 T+1 的基础上,增加一个基于 XXL-JOB 的“每2小时执行一次”的轮询短任务仅拉取当日订单增量数据入库实现伪实时监控依然无需引入 Webhook。
---
## 四、 边界条件与容错机制策略
1. **接口频控策略 (Rate Limiting)**
- 有赞 API 严格限制每秒 QPS。系统在统一的 API 调用 HTTP Client 封装层,使用 Guava `RateLimiter` 控制并发调用速率,超出部分排队等待,避免触发 `429 Too Many Requests` 封禁。
2. **数据入库幂等性设计 (Idempotency)**
- 由于采用增量拉取机制,时间窗口可能会发生重叠(例如拉取昨日 23:55 到今日 00:05 的数据),甚至同一笔订单在不同时间因为售后状态的改变被拉取多次。中台数据库必须以 `tid` (订单号) 为主键(或辅助唯一复合索引),所有的订单写入底层必须转换为 `UPSERT` 操作MySQL 中的 `ON DUPLICATE KEY UPDATE`),确保被重复拉取的数据不会造成销售额的重复计算。
3. **冷启动历史数据抓取:**
- 系统首次上线时,编写独立的 `HistoryDataSyncService` 配合 XXL-JOB 分片,基于时间范围参数,循环按天倒推调用 API 获取过去至少 6 个月的历史订单与客户数据,以建立初始的回归基线并冷启动 RFM 模型及推荐算法。

View File

@@ -0,0 +1,123 @@
# 餐饮零售数据中台 V2.0 - 有赞云接口对应表(能力包维度)
**文档版本:** V1.0
**说明:** 本表基于“能力包名称”整理,不使用未验证的具体接口方法名。待在控制台确认“能力包详情页的字段/接口清单”后,再补齐字段映射。
---
## 一、交易与售后域(订单净额与复购口径)
| 能力包名称 | 核心接口 (API) | 权限状态 | 用途 | 关键字段(待确认) |
|---|---|---|---|---|
| 订单同步 | `youzan.trades.sold.get` | 已获得 | 订单主表与明细拉取,构建销售事实表 | 订单号 (`tid`), 支付时间 (`pay_time`), 实付金额 (`pay_info.payment`), 订单状态 (`status`), 用户 (`buyer_info.buyer_phone`, `buyer_info.yz_open_id`), 明细列表 (`orders` 包含 `sku_id`, `num`, `price`) |
| 售后单同步 | `youzan.trade.refund.search` | 已获得 | 退款/退货列表与详情,用于净额口径 | 售后单号 (`refund_id`), 关联订单 (`tid`), 退款金额 (`refund_fee`), 退款状态 (`status`) |
| 售后单审核处理 | `youzan.trade.refund.agree` / `reject` | 已获得 | 售后状态变化的最终一致性 | 售后单状态 (`status`), 处理时间 (`updated_time`) |
| 网店仅退款 | `youzan.trade.refund.search` | 已获得 | 网店退款数据补充 | 售后单号 (`refund_id`), 金额 (`refund_fee`), 原因 (`reason`) |
| 网店退货退款 | `youzan.trade.refund.search` | 已获得 | 网店退货退款数据补充 | 退货单号 (`refund_id`), 金额 (`refund_fee`) |
| 门店仅退款基于有赞POS | `youzan.trade.refund.search` | 已获得 | 门店退款数据补充 | 门店ID (`offline_id`), 售后单号 (`refund_id`), 金额 (`refund_fee`) |
| 门店退货退款基于有赞POS | `youzan.trade.refund.search` | 已获得 | 门店退货退款数据补充 | 门店ID (`offline_id`), 售后单号 (`refund_id`), 金额 (`refund_fee`) |
**口径建议:**
- 销售额以“支付成功订单金额 - 完成退款金额”为净额。
- 退款/退货必须与订单明细关联避免SKU动销失真。
---
## 二、商品与SKU域商品洞察基础
| 能力包名称 | 核心接口 (API) | 权限状态 | 用途 | 关键字段(待确认) |
|---|---|---|---|---|
| 商品查询 | `youzan.item.search`, `youzan.item.detail` | 已获得 | 商品与SKU基础信息 | 商品ID (`item_id`), SKU ID (`sku_id`), 外部编码 (`outer_id`, `outer_item_id`), 名称 (`title`), 售价 (`price`) |
| 商品更新 | `youzan.item.update` | 已获得 | 未来可选:打折标识或状态调整 | 商品ID (`item_id`), 价格 (`price`), 状态 (`is_listing`) |
| 上下架商品 | `youzan.item.update.listing` | 已获得 | 上下架策略执行 | 商品ID (`item_id`), 上下架状态 |
| 商品分组 | `youzan.itemcategories.get` | 已获得 | 运营分组维度 | 分组ID (`group_id`), 分组名称 (`group_name`) |
| 商品标准 | `youzan.item.standard.search` | 已获得 | 标准化信息辅助归类 | 标准ID, 标准名称 |
| 门店商品管理 | `youzan.multistore.goods.sku.search` | 已获得 | 门店维度商品归属 | 门店ID (`kdt_id`/`offline_id`), 商品ID (`item_id`) |
| 店铺商品上下架到网点 | `youzan.multistore.goods.listing` | 已获得 | 网点商品状态 | 网点ID (`offline_id`), 商品ID, 状态 |
| 多网点商品关联配送方式 | `youzan.multistore.goods.delivery` | 已获得 | 配送方式区分热食/冷链 | 网点ID (`offline_id`), 配送方式 (`delivery_template_id`) |
**口径建议:**
- 使用“商家外部编码/自定义字段”建立热食与冷链SKU的统一映射若有
- 若无统一编码需人工维护“SKU映射表”。
---
## 三、客户与会员域(客户洞察基础)
| 能力包名称 | 核心接口 (API) | 权限状态 | 用途 | 关键字段(待确认) |
|---|---|---|---|---|
| 店铺客户信息同步 | `youzan.users.info.query`, `scrm.customer.get` | 已获得 | 客户主数据 | 有赞账户ID (`yz_open_id`/`buyer_id`), 手机号 (`mobile`), 注册时间 (`created_at`) |
| 店铺客户标签管理 | `youzan.scrm.customer.tags.get` | 已获得 | 标签分层与画像 | 标签ID (`tag_id`), 标签名 (`tag_name`), 账户ID (`yz_open_id`) |
| 会员等级打通 | `youzan.scrm.customer.level.get` | 已获得 | 等级分层 | 等级ID (`level_id`), 等级名称 (`level_name`) |
| 会员权益卡打通 | `youzan.scrm.card.list` | 已获得 | 权益识别 | 权益卡ID (`card_id`), 有效期 |
| 微信粉丝关联有赞用户 | `youzan.users.info.query` | 已获得 | 微信粉丝与有赞用户关系 | 微信应用ID (`app_id`), `open_id`, `union_id`, 关联账户 (`yz_open_id`) |
| 微信粉丝查询 | `youzan.users.weixin.follower.get` | 已获得 | 微信粉丝基础信息 | 粉丝标签, 关注状态, 关注时间 |
| 微信粉丝标签管理 | `youzan.users.weixin.follower.tags.get` | 已获得 | 粉丝标签画像 | 标签ID, 标签名, 粉丝标识 (`open_id`) |
**口径建议:**
- OneID 以手机号为主键,必须输出“识别率”指标(手机号覆盖率)。
- 若手机号缺失,仅做同端用户画像,不做跨端强绑定。
- 微信粉丝能力可用于“公众号触达+人群标签”,但需与 buyer_id 关联可用率配套监控。
---
## 四、门店/网点与仓库域
| 能力包名称 | 核心接口 (API) | 权限状态 | 用途 | 关键字段(待确认) |
|---|---|---|---|---|
| 网店门店仓库信息同步 | `youzan.multistore.offline.search` | 已获得 | 门店/网点/仓库主数据 | 门店ID、名称、类型、地址、状态 |
---
## 五、库存与进销存域(动销、损耗、补货)
| 能力包名称 | 核心接口 (API) | 权限状态 | 用途 | 关键字段(待确认) |
|---|---|---|---|---|
| ERP全渠道库存同步 | `youzan.inventory.quantity.update` | 已获得 | 库存总量与门店库存 | SKU、门店ID、可用库存 |
| 多网点有赞库存同步线下 | `youzan.retail.open.stock.sku.get` | 已获得 | 有赞 -> 线下库存同步 | SKU、门店ID、库存变化 |
| 多网点线下库存同步有赞 | `youzan.retail.open.stock.sku.update` | 已获得 | 线下 -> 有赞库存同步 | SKU、门店ID、库存变化 |
| 网店库存调整 | `youzan.inventory.quantity.sync` | 已获得 | 盘盈盘亏/调整 | SKU、门店ID、调整数量 |
| 库存盘点 | `youzan.retail.open.inventory.check` | 已获得 | 盘点差异 | 盘点单ID、差异数量 |
| 库存采购 | `youzan.retail.open.purchase.order` | 已获得 | 进货记录 | 采购单ID、SKU、数量、时间 |
| 采购退货 | `youzan.retail.open.purchase.return` | 已获得 | 进货退货 | 退货单ID、SKU、数量、时间 |
| 库存调拨 | `youzan.retail.open.allocate.order` | 已获得 | 门店间调拨 | 调拨单ID、来源/目标门店、SKU、数量 |
| 连锁库存同步(总部/网店) | `youzan.retail.open.stock.chain.sync` | 已获得 | 连锁库存结构 | 总部/网店库存状态 |
**口径建议:**
- 以“采购/调拨/盘点/调整”构建库存流水,配合订单明细计算真实动销与损耗。
- 若有报损字段,需单独建“报损原因”维度。
---
## 六、营销与评价域(运营辅助)
| 能力包名称 | 核心接口 (API) | 权限状态 | 用途 | 关键字段(待确认) |
|---|---|---|---|---|
| 营销活动查询 | `youzan.ump.activities.get` | 已获得 | 活动效果评估 | 活动ID (`activity_id`), 活动类型 (`activity_type`) |
| 优惠券管理 | `youzan.ump.voucher.search`, `voucheractivity.send` | 已获得 | 优惠券活动、发放与核销 | 券组ID (`coupon_group_id`), 券码 (`verify_code`), 发放/核销时间, 核销门店 (`offline_id`) |
| 限时折扣 | `youzan.ump.limitdiscount.get` | 已获得 | 折扣与动销关联 | 折扣活动ID (`activity_id`), SKU (`sku_id`), 折扣价 |
| 自定义会员价 | `youzan.ump.memberprice.get` | 已获得 | 会员价影响评估 | SKU (`sku_id`), 会员等级 (`level_id`), 价格 (`price`) |
| 评价管理 | `youzan.trade.rate.get` | 已获得 | 口碑与复购关联 | 订单号 (`tid`), 评分, 评价时间 |
| 电子卡券核销 | `youzan.ump.ticket.verify` | 已获得 | 卡券核销记录 | 卡券码 (`verify_code`), 核销时间, 核销门店 (`offline_id`) |
**说明:** 已具备优惠券发放与核销能力,可支持“自动化发券闭环”。
---
## 七、同步策略(建议)
1. **订单/售后**:日级增量 + T+1 对账
2. **库存/进销存**:日级增量(必要时加小时级)
3. **商品/会员/门店**:日级全量或按更新时间增量
4. **营销活动**:日级全量
5. **优惠券/核销**:日级增量
6. **实时能力**:当前按“非实时”设计,若未来需要实时再评估 Webhook/消息订阅能力包
---
## 八、待确认清单(接口联调前最后核对)
1. **API 返回的字段结构层级**:如 `orders` 数组内的商品明细嵌套深度,需在控制台/Postman调用后确认。
2. **手机号解密**:有赞对部分手机号 (`mobile`) 会进行脱敏或加密返回(依赖数据安全组件),需确认业务上是否已开通手机号明文获取权限,否则 OneID 需降级使用 `yz_open_id` / `union_id`
3. **退款金额类型**:确认 `refund_fee` 是否只包含实退金额,是否扣除运费和营销抵扣。
4. **外部商家编码 (`outer_id`)的必填约束**:请业务侧运营保证所有新商品上架时,必须规范填写 `outer_id`(针对冷链版和热食版统一编码),中台逻辑才可闭环。

View File

@@ -0,0 +1,146 @@
# 搭建 Spring Boot 有赞数据中台服务
基于已完成的设计文档和数据库 DDL`AIMachineSystem/server/` 下搭建 Spring Boot 服务覆盖数据同步、洞察计算、API 接口三层。
## 全局蓝图5 Phase
| Phase | 内容 | 层 | 定时 |
|---|---|---|---|
| **Phase 1** | 订单同步 (`trades.sold.get``dwd_trade_order_detail`) | 同步层 | 每日 02:00 |
| **Phase 2** | 商品同步 + 客户同步 | 同步层 | 每日 02:30 |
| **Phase 3** | 库存流水 + 优惠券 + 退款同步 | 同步层 | 每日 03:00 |
| **Phase 4** | RFM 计算、动销排名、购物篮分析、客群分层 | 洞察层 | 每日 04:00 |
| **Phase 5** | REST API仪表盘接口、导出接口 | 接口层 | 实时 |
## 技术选型确认
- **Java 17** / Spring Boot 3.2.x
- **MyBatis-Plus 3.5.x** (持久层)
- **Spring @Scheduled** (定时任务)
- **RestTemplate** (HTTP 调用有赞 API)
- **有赞应用类型:自用型**`grant_type=silent`,直接用 `client_id + client_secret + kdt_id` 获取 Token
## 项目目录结构
```
server/
├── pom.xml
└── src/main/
├── java/com/chuyishidai/datahub/
│ ├── DataHubApplication.java
│ ├── config/
│ │ └── YouzanConfig.java # 有赞配置 + RestTemplate Bean
│ ├── entity/ # MyBatis-Plus Entity对齐 DDL
│ │ ├── TradeOrderDetail.java
│ │ ├── TradeRefundDetail.java
│ │ ├── ItemSku.java
│ │ ├── CustomerInfo.java
│ │ ├── OfflineStore.java
│ │ ├── InventoryFlow.java
│ │ └── CouponFlow.java
│ ├── mapper/ # MyBatis-Plus Mapper
│ ├── service/
│ │ ├── youzan/ # 有赞 API 基础设施
│ │ │ ├── YouzanTokenService.java # Token 获取与缓存
│ │ │ └── YouzanApiClient.java # API 调用封装
│ │ ├── sync/ # Phase 1-3: 数据同步
│ │ │ ├── TradeSyncService.java
│ │ │ ├── ItemSyncService.java
│ │ │ ├── CustomerSyncService.java
│ │ │ ├── InventorySyncService.java
│ │ │ └── CouponSyncService.java
│ │ └── insight/ # Phase 4: 洞察计算
│ │ ├── ProductInsightService.java
│ │ ├── CustomerInsightService.java
│ │ └── InventoryInsightService.java
│ ├── controller/ # Phase 5: REST API
│ │ ├── ProductController.java
│ │ ├── CustomerController.java
│ │ └── DashboardController.java
│ └── job/ # 定时任务
│ ├── TradeSyncJob.java
│ └── InsightComputeJob.java
└── resources/
└── application.yml
```
## 当前实施范围Phase 1订单同步
### [NEW] pom.xml
Spring Boot 3.2 + MyBatis-Plus + MySQL + Lombok + Jackson
### [NEW] application.yml
数据源、有赞 API 配置、cron 表达式
### [NEW] YouzanTokenService.java
自用型 Token`POST https://open.youzanyun.com/auth/token`body: `client_id + client_secret + grant_type=silent + kdt_id`。缓存至 80% TTL 自动刷新。
### [NEW] YouzanApiClient.java
通用调用:`GET https://open.youzanyun.com/api/{apiName}/{version}?access_token=xxx`,带重试(最多 3 次)。
### [NEW] TradeOrderDetail.java + Mapper
MyBatis-Plus Entity 完全对齐 DDL 的 `dwd_trade_order_detail`
### [NEW] TradeSyncService.java
1.`update_time` 窗口调用 `youzan.trades.sold.get/4.0.4` 分页拉取
2. 展开 `full_order_info_list[].orders[]` 到 SKU 粒度
3. `saveOrUpdateBatch`(基于 `oid` 唯一键UPSERT
### [NEW] TradeSyncJob.java
`@Scheduled(cron = "0 0 2 * * ?")` 每日凌晨 2 点触发,拉取前一天增量。
## 当前实施范围Phase 4商品洞察与会员洞察
### 数据模型设计 (ADM 层) - `sql/phase4_ddl.sql`
1. **`adm_customer_rfm` (客户 RFM 洞察表)**:
- 计算逻辑:基于过去 90 天订单流水,聚合 R最近消费、F消费频次、M消费金额
- 包含 RFM 评分与系统自动划分的客群层级(如重要价值客户、沉睡客户)。
2. **`adm_item_sales_trend` (商品销售趋势表)**:
- 计算逻辑:按天聚合 `dwd_trade_order_detail`,统计各个 SKU 的单量、销量、销售额。用于生成智能订货预测。
3. **`adm_item_basket` (关联销售分析表)**:
- 计算逻辑:采用简化版 Apriori 算法思想,统计同一订单 (tid) 内商品对 (Item A, Item B) 的同现频率与置信度。
4. **`adm_item_repurchase` (商品复购分析表)**:
- 计算逻辑:统计某 SKU 购买过后的用户,在接下来 30 天内再次购买的比例。
### 业务逻辑层设计 (Service)
1. **`CustomerInsightService`**:
- `computeRfm()`: 执行 SQL 统计与评分,生成/更新会员 RFM 白皮书。
- `computeUserTags()`: 针对购买行为(如工作日午餐、高客单价)给用户打标签。
2. **`ProductInsightService`**:
- `computeDailySalesTrend()`: 日终归集单日销售统计。
- `computeBasketRules()`: 统计过去 X 天的热门搭配。
- `computeRepurchaseRates()`: 更新商品的 30 天复购率指标。
3. **`InventoryInsightService`**:
- `computeStockAdvice()`: 基于库存流水和流速,生成次日拿货/备货单。
### 定时任务层设计 (Job)
1. **`InsightComputeJob`**:
- `@Scheduled(cron = "0 0 4 * * ?")` 每日凌晨 4:00 集中触发洞察层计算(由于数据全量计算,放置在凌晨数据同步完成之后)。
## 当前实施范围Phase 5可视化接口层
### API 设计方案
1. **`DashboardController` (核心监控板)**:
- `GET /api/dashboard/overview`: 今日总体指标 (今日实付金额, 今日退款金额, 订单数)
- `GET /api/dashboard/trend`: 近 14 天整体销售趋势变化折线图数据
2. **`ProductController` (商品洞察与预测)**:
- `GET /api/product/advice`: 智能订货单查询 (基于过去 14 天 `adm_item_sales_trend` 动态计算各单品日均流速,输出建议备货量)
- `GET /api/product/basket`: 购物篮高频组合列表 (查询 `adm_item_basket` 置信度排行)
- `GET /api/product/repurchase`: 单品复购率与波士顿矩阵打标查询 (查询 `adm_item_repurchase`)
3. **`CustomerController` (会员金字塔与人群)**:
- `GET /api/customer/rfm/distribution`: RFM 金字塔分布 (各层级人数及金额贡献占比查询)
- `GET /api/customer/tags`: 各类特征人群包人数统计 (查询 `adm_customer_tags`)
### 业务与数据聚合逻辑
由于数据在 Phase 4 已通过定时任务计算沉淀到了 `adm_` 系列汇总表中Phase 5 仅提供轻量级的 `SQL GROUP BY``SELECT` 汇总查询,通过 `JdbcTemplate``MyBatis-Plus` 向前端输出标准 JSON。
## Verification Plan
### Automated Tests
- `cd server && mvn compile` 编译通过验证
### Manual Verification
1. 重启 Spring Boot 服务。
2. 使用 Postman 或浏览器直接测试 `/api/dashboard/overview`, `/api/product/advice` 等接口,验证返回的 JSON 结构。

View File

@@ -0,0 +1,99 @@
-对于Java包更推荐使用Maven工具安装管理。
参照以下代码示例更新最新版本在Maven中直接引用即可并且请在settings.xml中引入有赞云maven仓库。
<dependency>
<groupId>com.youzan.cloud</groupId>
<artifactId>open-sdk-core</artifactId>
<version>1.0.28-RELEASE</version>
</dependency>
<dependency>
<groupId>com.youzan.cloud</groupId>
<artifactId>open-sdk-gen</artifactId>
<version>1.0.22.80701202109281055-RELEASE</version>
</dependency>
<mirrors>
<mirror>
<id>youzan-nexus-snapshot</id>
<name>Maven Repository Mirror running on maven.youzanyun.com</name>
<url>http://maven.youzanyun.com/repository/maven-public</url>
<mirrorOf>central</mirrorOf>
</mirror>
</mirrors>
<profiles>
<profile>
<id>dev</id>
<repositories>
<repository>
<id>public</id>
<name>public repository</name>
<url>https://maven.youzanyun.com/repository/maven-public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>public</id>
<name>public repository</name>
<url>https://maven.youzanyun.com/repository/maven-public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>true</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>dev</activeProfile>
</activeProfiles>
【说明】
-SDK使用的是OkHttp3已经暴露了对应的配置方法可自行根据业务进行配置参考如下示例。
OkHttpClient.Builder builder = new OkHttpClient.Builder().readTimeout(5, TimeUnit.HOURS);
HttpConfig httpConfig = HttpConfig.builder().OkHttpClientBuilder(builder).build();
YouZanClient youZanClient = new DefaultYZClient(httpConfig);
-Java open SDK依赖包引入示例如下。
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.54</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.4</version>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>3.12.0</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>27.0.1-jre</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.12</version>
</dependency>
<dependency>
<groupId>com.squareup.okio</groupId>
<artifactId>okio</artifactId>
<version>1.15.0</version>
</dependency>

View File

@@ -0,0 +1,97 @@
# 自用型应用获取和刷新 access_token
## 1. 接口说明
* **接口功能**:用于“自用型应用”获取及刷新调用有赞云 API 所需的身份验证凭证(`access_token`)。
* **前提条件**:已在有赞云控制台完成自用型应用的创建,并且完成了店铺授权。
* **业务注意**
1. 一个店铺只能授权给一个自用型应用,而一个自用型应用可以有多个店铺授权(多店授权需通过有赞云审核)。
2. 建议开发者在代码中全局捕获 Token 失效的错误码,并实现 `access_token` 失效时自动重新获取的逻辑。
---
## 2. 请求说明
* **请求地址**`https://open.youzanyun.com/auth/token`
* **请求方式**`POST`
* **请求头 (Header)**`Content-Type: application/json`
> ⚠️ **注意**:必须严格使用 `POST` 加上 `application/json` 的请求方式,否则会报错。
---
## 3. 请求参数
在请求的 BodyJSON格式中传入以下参数
| 参数名称 | 类型 | 是否必须 | 示例值 | 描述说明 |
| :--- | :--- | :---: | :--- | :--- |
| `client_id` | String | **是** | `239ec70db50cfd...` | 有赞云颁发给开发者的应用 ID [1] |
| `client_secret` | String | **是** | `9ea25ab4513b7f...` | 有赞云颁发给开发者的应用密钥 (AppSecret) [1] |
| `authorize_type` | String | **是** | `silent` | 授权方式。自用型应用固定为 `"silent"` [1] |
| `grant_id` | String | **是** | `123456` | 授权店铺ID`kdt_id`。如果是API接口对接传店铺ID如果是支付商户对接传 `mchId` [1] |
| `refresh` | Boolean | 否 | `false` | 是否需要返回 `refresh_token`。默认为 `false`。如需通过 `refresh_token` 刷新凭证,需传入 `true` [1] |
*(注:`client_id`、`client_secret` 和店铺ID `grant_id`,请前往有赞云控制台的应用详情和授权页面查看 [1]。)*
---
## 4. 响应参数
响应主体为 JSON 格式,核心数据包裹在 `data` 节点中:
| 参数名称 | 类型 | 描述说明 |
| :--- | :--- | :--- |
| `success` | Boolean | 请求是否成功 |
| `code` | Integer | HTTP 状态码,`200` 表示成功 |
| `data.access_token` | String | 用于调用业务 API 接口的有效凭证 |
| `data.expires_in` | Long | `access_token` 的有效时间(单位:秒,通常为 7 天 / 604800秒 |
| `data.authority_id` | String | 授权店铺的 ID`kdt_id` |
| `data.refresh_token` | String | 用于刷新 `access_token` 的凭证(仅在请求参数 `refresh``true` 时返回) |
---
## 5. 请求与响应示例
### 5.1 CURL 请求示例 [1]
```bash
curl -X POST https://open.youzanyun.com/auth/token \
-H 'content-type: application/json' \
-d '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"authorize_type": "silent",
"grant_id": "88888",
"refresh": false
}'
```
### 5.2 成功响应示例
```json
{
"success": true,
"code": 200,
"data": {
"access_token": "f59b1a6bb04f4eqweqd1c6af315d",
"expires_in": 604800,
"authority_id": "88888",
"refresh_token": "c3b21a6bb04f4eqweqd1c6af315d"
},
"message": null
}
```
---
## 6. 🔄 如何刷新 access_token
对于**自用型应用**,有赞云提供了两种刷新/重新获取 token 的策略:
### 方式一:直接静默重新获取(推荐做法)
因为自用型应用采用的是 `silent`(静默)授权模式,无需商家人工干预。因此当 `access_token` 过期时,**直接再次发送和首次获取时完全一样的 POST 请求**`authorize_type` 依然为 `silent`),即可直接生成并获取到一个全新的 `access_token`
### 方式二:通过 refresh_token 刷新
1. 在首次获取 token 时,必须将入参的 `refresh` 字段设置为 `true` [2],此时响应数据中会返回一个 `refresh_token`
2.`access_token` 过期后,可以改变请求参数来刷新 Token
*`authorize_type` 的值修改为 `"refresh_token"` [2]
* 增加传递 `"refresh_token": "你获取到的refresh_token字符串"` [2]
* 仍然携带 `client_id``client_secret` [2]
*(建议:业务中采取在 Redis / 本地缓存中托管 Token 及到期时间,在到期前提前 10-30 分钟使用方式一重新拉取即可。)*

View File

@@ -0,0 +1,26 @@
获取和刷新 access_token说明
access_token说明
1. 当请求 API 时access_token 提示失效或过期,请重新获取。
2. 若因网络等原因未能成功获取新的 access_token在 1 个小时内仍可重新获取,多次重复调用拿到的是同一个 access_token。
3. 开发者需要缓存 access_token不能频繁调用否则会受到调用频率限流请合理使用 access_token 的有效期。
4. 旧access_token 在有效期内,当 refresh=true 时生成新的token旧token会在一小时后失效;
- 旧access_token已过期当 refresh=true 时生成新的token
- 旧access_token 在有效期内,当 refresh=false 时返回旧的token不会生成新的token也不会给的旧token续期;
- 旧access_token已过期当 refresh=false 时生成新的token。
5. 当多个店铺授权给同一个应用时,每个应用对应的店铺的 access_token 是彼此独立的,所以缓存时需要区分店铺 id。
6. 一个店铺只能授权给一个自用型应用,而一个自用型应用可以有多个店铺授权(需要有赞云审核)。
7. 因授权实现业务需要,建议开发者应实现 access_token 失效时重新获取的逻辑正常情况下生成的token有7天有效期接口返回expires是失效时间
8. 开发者需要缓存 access_token不能频繁调用否则会受到调用频率限流请合理使用 access_token 的有效期。
前置条件
已完成应用的创建,并且完成店铺授权。
- 自用型、工具型应用类型的获取方式有区别;
- 有容器、无容器应用类型获取方式一致。
工具型与自用型区别
工具型
- 授权方式authorize_type=“authorization_code”需要店铺授权 code 参数。
- 通过返回的refresh_token进行access_token有效期刷新。
自用型
- 授权方式authorize_type=“silent”需要店铺 id。
- 通过参数refresh参数直接刷新。

View File

@@ -0,0 +1,115 @@
# 订单批量查询接口 (youzan.trades.sold.get.4.0.4)
## 1. 接口基本信息
* **API 名称**:订单批量查询接口
* **API 接口名**`youzan.trades.sold.get`
* **版本号**`4.0.4` 4.0.2 及以上版本共用同一套底层结构)
* **请求协议**HTTPS
* **请求方式**GET/POST (推荐 POST `application/json`)
## 2. 接口描述
用于按条件批量搜索和查询有赞店铺的订单列表数据。该版本接口已全面升级 `yz_open_id` 作为买家唯一标识,**目前不支持返回 `buyer_id`**。
### ⏱️ 核心查询与排序规则
支持基于订单的创建时间、更新时间和完成时间进行获取。
* **创建时间**:可以获取指定时间内所有的订单。
* **更新时间**:可以获取最新更新的订单,但是该范围内的订单是动态变化的。
* **完成时间**:可以获取指定时间内已完成的订单。
**接口内置排序规则(优先级)**
1. 只有创建时间 `start_created``end_created`,按 **创建时间** 排序。
2. 只有更新时间 `start_update``end_update`,按 **更新时间** 排序。
3. 只有完成时间 `start_success``end_success`,按 **完成时间** 排序。
4. 同时存在创建时间、更新时间,按 **更新时间** 排序。
5. 同时存在创建时间、完成时间,按 **完成时间** 排序。
6. 同时存在更新时间、完成时间,按 **完成时间** 排序。
7. 同时存在创建时间、更新时间、完成时间,按 **完成时间** 排序。
8. **重要提醒**:如创建或更新或完成时间的开始和结束时间不是**成对出现**,则该时间条件无效。
---
## 3. 请求地址
```http
https://open.youzanyun.com/api/youzan.trades.sold.get/4.0.4
```
---
## 4. 请求参数 (全量)
| 参数名称 | 参数类型 | 必填 | 示例值 | 参数说明 |
| :--- | :--- | :---: | :--- | :--- |
| `access_token` | String | **是** | `9b510e232c9...`| 鉴权凭证,用于请求 API |
| `type` | String | 否 | `NORMAL` | 订单类型。`NORMAL`: 普通订单; `PEERPAY`: 代付; `GIFT`: 我要送人; `GROUP`: 拼团; `HOTEL`: 酒店; `TAKE_AWAY`: 外卖; `CATERING_OFFLINE`: 堂食点餐; `CATERING_QRCODE`: 外卖买单; `KNOWLEDGE_PAY`: 知识付费 等 |
| `status` | String | 否 | `WAIT_SELLER...` | 订单状态(一次只能查一种状态)。<br>`WAIT_BUYER_PAY`: 待付款<br>`WAIT_SELLER_SEND_GOODS`: 待发货<br>`WAIT_BUYER_CONFIRM_GOODS`: 待确认收货<br>`TRADE_SUCCESS`: 订单完成<br>`TRADE_CLOSED`: 订单关闭<br>`TRADE_REFUND`: 退款中 |
| `start_created` | Date | 否 | `2023-10-01 00:00:00`| 创建开始时间 |
| `end_created` | Date | 否 | `2023-10-01 23:59:59`| 创建结束时间 |
| `start_update` | Date | 否 | `2023-10-01 00:00:00`| 更新开始时间 |
| `end_update` | Date | 否 | `2023-10-01 23:59:59`| 更新结束时间 |
| `start_success` | Date | 否 | `2023-10-01 00:00:00`| 交易完成开始时间 |
| `end_success` | Date | 否 | `2023-10-01 23:59:59`| 交易完成结束时间 |
| `delivery_start_time` | Date | 否 | `2023-10-01 00:00:00`| 发货/配送/自提 期望开始时间 |
| `delivery_end_time` | Date | 否 | `2023-10-01 23:59:59`| 发货/配送/自提 期望结束时间 |
| `page_no` | Integer| 否 | `1` | 页码,从 1 开始。**页码最大不能超过 100**,如需查询更多数据,必须按时间分段查询。 |
| `page_size` | Integer| 否 | `20` | 每页条数,默认 20 条,**最大不能超过 100**。 |
| `tid` | String | 否 | `E202310011...`| 有赞订单号(精确搜索查询) |
| `yz_open_id` | String | 否 | `V1abcdef123...` | 买家在有赞的唯一标识 ID |
| `receiver_name` | String | 否 | `张三` | 收件人姓名 |
| `receiver_phone` | String | 否 | `13800138000` | 收件人手机号 |
| `offline_id` | Long | 否 | `123456` | 门店/网点 ID传入后只查归属该网点的订单 |
| `node_kdt_id` | Long | 否 | `88888` | 连锁门店网点店铺 ID用于多网点/连锁总店拉取特定店单据场景) |
| `goods_id` | Long | 否 | `987654321` | 商品 ID筛选包含该商品的订单 |
| `goods_title` | String | 否 | `薯片` | 商品标题(模糊搜索包含该标题的订单) |
| `keywords` | String | 否 | `苹果` | 搜索关键字(模糊匹配商品名、订单号等) |
| `express_type` | String | 否 | `EXPRESS` | 物流类型。`EXPRESS`: 快递配送; `LOCAL_DELIVERY`: 同城送; `SELF_FETCH`: 自提 |
| `fans_id` | Long | 否 | `0` | 买家有赞粉丝 ID |
| `fans_type` | Integer| 否 | `1` | 买家粉丝类型 |
| `exclude_order_tag`| String | 否 | `xxx` | 排除包含特定标签的订单 |
| `need_order_url` | Boolean| 否 | `false` | 是否需要返回前端该订单详情的 H5 链接 |
| `star` | Integer| 否 | `1` | 卖家星标备注过滤1-5 对应标记的星级颜色) |
---
## 5. 响应参数 (全量展开)
返回主体内以 `data` 包裹,核心业务数据在 `full_order_info_list` 中以数组形式返回。
| 根级参数名称 | 参数类型 | 描述说明 |
| :--- | :--- | :--- |
| `total_results` | Integer | 搜索条件匹配到的订单总数 |
| `has_next` | Boolean | 是否还有下一页(用于辅助判定分页) |
| `full_order_info_list`| Array | 完整的订单信息列表集合 |
### 5.1 `full_order_info_list` 内部对象全量解构
列表内的每一项包裹了一个 `full_order_info` 对象,内部包含了关于这笔交易各个维度的子结构体:
| 子结构体名称 | 内部包含的核心全量字段与说明 |
| :--- | :--- |
| **`order_info`**<br>*(交易基础信息)* | `tid`(有赞订单号)<br>`status`(主订单状态枚举,如 `WAIT_SELLER_SEND_GOODS`)<br>`status_str`(状态中文描述文字)<br>`type`(主订单类型:`0`普通 `10`拼团 等)<br>`backstage_order_type`(后台订单类型标识,如 `NORMAL``CROSS_BORDER`)<br>`pay_type`(支付方式枚举值) |
| **`buyer_info`**<br>*(买家信息)* | `yz_open_id`(买家有赞唯一身份标识,**对账核心**)<br>`buyer_phone`(买家手机号,可能存在脱敏加密)<br>`fans_id`(粉丝ID)`fans_type`(粉丝类型)<br>`fans_nickname`(粉丝昵称)<br>`outer_user_id`(外部用户ID)<br>*(⚠️ 注:此接口文档明确不返回原版 `buyer_id`)* |
| **`pay_info`**<br>*(支付与金额信息)* | `total_fee`(订单总金额)`post_fee`(运费)<br>`payment`(实付总金额)`real_payment`(实际支付现金)<br>`deduction_pay`(虚拟抵扣金额,如积分/余额抵扣)<br>`deduction_real_pay`(实际抵扣金额)<br>*(注:金额类型字段为 String 格式,单位:**元**)* |
| **`address_info`**<br>*(收件与物流信息)* | `receiver_name`(收件人姓名)`receiver_tel`(收件人电话)<br>`delivery_province`(省份)`delivery_city`(城市)<br>`delivery_district`(区县)`delivery_address`(详细地址)<br>`delivery_postal_code`(邮编)<br>`address_extra`(JSON 格式的经纬度扩展,如 `{"lon":120.1,"lat":30.4}`)<br>`self_fetch_info`(自提点门店详情)<br>`delivery_start_time`/`delivery_end_time`(期望配送或提货时间) |
| **`remark_info`**<br>*(备注与评价信息)* | `buyer_message`(买家留言)<br>`trade_memo`(卖家内部备注)<br>`star`(订单星标等级 0~5) |
| **`orders`** (Array)<br>*(商品明细列表)* | **包裹在该订单内的所有独立商品 SKU 数组:**<br>`item_id`(商品ID)`sku_id`(规格ID)<br>`outer_item_id`(外部商品/SPU商家编码)<br>`outer_sku_id`(外部规格/SKU商家编码)<br>`title`(商品名称)`price`(单价,单位元)<br>`num`(购买数量)`discount_fee`(优惠分摊金额)<br>`payment`(该商品最终实付金额)`is_cross_border`(是否海淘商品) |
| **`child_info`**<br>*(子单信息)* | 包含 `child_orders` 数组。当出现订单因拆包或部分发货时会产生关联的子订单号(通常取自其内部的 `tid` 用于局部发货接口)。 |
| **`invoice_info`**<br>*(发票信息)* | `taxpayer_id`(买家填写的发票税号/抬头等) |
---
## 6. ⚠️ 开发者防坑指南与注意事项
1. **接口同步延时(非常重要)**
批量接口底层数据有一定的同步延时。**强烈建议在收到订单相关的交易消息推送Webhook间隔大于 30 秒再调用此接口查询**。如果是主动定时轮询拉取订单,同样建议将查询时间范围整体向后延迟 30 秒(例如当前是 10:00:00只查 09:59:30 之前的数据)。如果刚付款立刻查可能返回为空,遇到返回空可以实施重试机制。
2. **分页与深度翻页限制**
`page_no``page_size` 乘积代表查询深度,单页最大 100 条且**页码不能超过 100 页**。在系统对接进行大批量拉取时,必须采用**时间切片法**(如每 1 小时或每 30 分钟为一个区间,循环请求),不能单靠增加页码来拉取海量历史数据。
3. **退款订单的特殊性**
`start_success` (完成时间) 搜索,查到的是状态为“交易成功”的订单。如订单发生全额退款,其状态会变为 `TRADE_CLOSED`(交易关闭)。若需处理售后退单业务,须配合有赞退款 API`youzan.trade.refund.search`)或交易消息服务使用。
4. **老订单降级说明**
由于该版本数据架构调整,若需查询 **2020 年以前的极早期订单数据**,请降级调用 `youzan.trades.sold.get.4.0.0` 接口,新版接口无法查出早于 2020 年的历史数据。

View File

@@ -0,0 +1,206 @@
# 分页查询微商城销售中和已售罄商品列表 (youzan.item.search.3.0.0)
## 1. 接口基本信息
* **API 名称**:分页查询微商城销售中和已售罄商品列表
* **API 接口名**`youzan.item.search`
* **版本号**`3.0.0`
* **计费规则**:基础接口,调用计费(具体以控制台能力包购买为准)。
* **接口描述**:用于分页综合查询处于“**销售中**”和“**已售罄**”状态的商品列表。支持按关键字、分组、品牌、类目、更新时间等多种维度组合筛选。
* **⚠️ 注意**:此接口**无法查询到“仓库中”的商品**(如下架商品),若需查询下架或仓库中商品需调用 `youzan.items.inventory.get`
---
## 2. 请求说明
* **请求协议**HTTPS
* **请求方式**GET/POST (推荐 POST `application/json`)
* **请求地址**
```http
https://open.youzanyun.com/api/youzan.item.search/3.0.0
```
---
## 3. 请求参数 (全量)
| 参数名称 | 类型 | 是否必须 | 示例值 | 参数说明 |
| :--- | :--- | :---: | :--- | :--- |
| `access_token` | String | **是** | `9b510e232c9...` | 有赞云 API 鉴权凭证 |
| `q` | String | 否 | `薯片` | 搜索字段,支持搜索:商品名(模糊匹配) |
| `item_ids` | String | 否 | `457692126,45745...` | 作为查询条件的商品 ID 列表,以逗号分隔,用于精确筛选 |
| `page_no` | Integer | 否 | `1` | 页码,从 1~100 开始,分页数不能超过 100 页。`page_size` 和 `page_no` 相乘总条数不能大于 4000 条 |
| `page_size` | Integer | 否 | `20` | 每页数量,要求小于 50默认 20 |
| `order_by` | String | 否 | `update_time:desc` | 排序方式,格式为 `字段名:升降序`。<br>支持:`created_time`(创建时间)、`update_time`(更新时间)、`price`(价格)、`sold_num`(销量) |
| `update_time_start`| Long | 否 | `1563379200000` | 更新时间**起始**Unix时间戳**时间单位ms毫秒** |
| `update_time_end` | Long | 否 | `1563465600000` | 更新时间**截止**Unix时间戳**时间单位ms毫秒** |
| `show_sold_out` | Integer | 否 | `0` | 是否在售:`0`—在售,`1`—售罄或部分售罄,`2`—全部 |
| `tag_ids` | String | 否 | `106590,106593` | 作为查询的分组(标签) ID 列表,以逗号分隔 |
| `brand_ids` | String | 否 | `1254378,86662` | 作为查询的品牌 ID 列表,以逗号分隔 |
| `category_ids` | String | 否 | `67263,71822` | 作为查询的分类 ID 列表,以逗号分隔 |
| `leaf_category_id` | Long | 否 | `9723123` | 新版商品类目V4版本叶子类目 ID |
| `shop_org_ids` | List<Long>| 否 | `[123, 234]` | 店内组织末级节点 ID 列表,最大不能超过 200 |
| `node_kdt_id` | Long | 否 | `54731007` | 有赞连锁网店店铺 ID仅供连锁场景使用判断信息归属网店 |
| `search_code_type` | Integer | 否 | `3` | 编码查询类型:`1` 编码,`2` 条码,`3` 规格编码,`4` 规格条码 |
| `search_code_list` | List<String>| 否 | `["BM111","BM222"]` | 对应上述类型的编码数组,限制 100 个 |
| `item_source` | Integer | 否 | `0` | 商品创建类型:`0` 普通;`1` 网店自建 (仅支持连锁分店) |
| `offline_id` | Long | 否 | `58853441` | 多网点 ID |
| `channel` | Integer | 否 | `0` | 店铺渠道类型:`-1`:全部渠道;`0`:网店; `1`:门店。默认网店。 |
---
## 4. 响应参数
响应结果主体以 `data` 包裹,主要包含商品总数 `count` 和商品对象列表 `items`。
| 根级参数名称 | 类型 | 描述说明 |
| :--- | :--- | :--- |
| `success` | Boolean | 业务请求是否成功 (`true`/`false`) |
| `code` | Integer | 状态码,`200` 为成功 |
| `message` | String | 成功或错误的提示消息 |
| `data.count` | Integer | 搜索到的符合条件的商品总数量(用于前端计算总页数) |
| `data.items` | Array | 商品详细信息集合 |
### 4.1 `items` 数组内商品基础字段
| 字段名 | 类型 | 字段说明 |
| :--- | :--- | :--- |
| `item_id` | Long | 商品唯一标识(有赞内部商品 ID |
| `alias` | String | 商品别名,可用于拼接商品前端 H5/小程序详情页链接 |
| `title` | String | 商品名称/标题 |
| `sub_title` | String | 商品分享描述 / 副标题 |
| `price` | Long | 商品当前售卖价(单位:**分**,如 100 代表 1元 |
| `origin` | String | 划线价(单位:**元** |
| `item_no` | String | 商家自行填写的商品编码(支持英文和数字组合) |
| `quantity` | Long | 库存数量(注:门店渠道下会被放大 1000 倍展示) |
| `actual_quantity` | String | 实际总库存(自动处理门店渠道放大 1000 倍的转换问题,建议以该字段为准) |
| `item_type` | Integer | 商品类型:`0`普通 `3`降价拍 `5`外卖 `10`分销 `20`会员卡 `60`虚拟 `61`电子卡券等 |
| `channel` | Integer | 归属渠道类型:`0`网店 `1`门店 |
| `post_type` | Integer | 运费类型:`1`—统一运费,`2`—运费模板 |
| `post_fee` | Long | 统一运费的费用(单位:分) |
| `created_time` | String | 创建时间,格式 `"yyyy-MM-dd HH:mm:ss"` |
| `update_time` | String | 最后更新时间,格式 `"yyyy-MM-dd HH:mm:ss"` |
| `has_multi_sku` | Boolean | 是否包含多规格(多 SKU商品 |
| `tag_ids` | List<Long> | 商品分组(标签) ID 列表包含1、2级分组 |
| `group_names` | List<String>| 分组名称列表,如 `["全部分组", "T恤"]` |
| *(其他链接类)* | String | `image` (主图链接), `page_url` (小程序路径), `detail_url` (H5链接) |
### 4.2 `items` 内嵌复杂对象 (图片、运费、属性等)
在每个商品对象中,还包含以下级联对象,用于描述商品更立体的属性:
| 对象/数组名称 | 内部核心字段说明 |
| :--- | :--- |
| **`delivery_template`**<br>*(运费模板)* | `delivery_template_id`(模板ID), `delivery_template_name`(名称), `delivery_template_fee`(运费范围,单位:**元**), `delivery_template_valuation_type`(计费类型 `1`按件 `2`按重量 `3`体积) |
| **`item_imgs`**<br>*(图片列表)* | 包含商品轮播图等:`id`(图片ID), `url`(原图链接), `thumbnail`(缩略图), `medium`(中图), `created`(创建时间) |
| **`sku_extension_attributes`**<br>*(SKU扩展属性)* | `sku_id`(规格ID), `cost_price`(成本价) |
| **`meas_prop`**<br>*(重量与计量)* | 内部包裹 `meas` 数组。包含:`sku_id`(商品规格ID), `weight`(规格维度重量,单位:**克**) |
| **`properties`**<br>*(类目相关属性)* | 分为 `publics` (公域属性,如品牌、材质) 和 `privates` (私域属性,如商家自定义的颜色、尺码)。内含 `pro_name`(属性名) 和 `val_names`(属性值名称列表) |
*(注:如果需要对多规格商品进行深度的库存或价格修改,建议结合 `youzan.item.detail.get` 获取完整的 SKU 明细节点。)*
---
## 5. 请求与响应示例
### 5.1 Java SDK 调用示例
```java
YZClient client = new DefaultYZClient(new Token("YOUR_ACCESS_TOKEN"));
YouzanItemSearchParams youzanItemSearchParams = new YouzanItemSearchParams();
// 搜索关键字
youzanItemSearchParams.setQ("薯片");
// 设置按更新时间降序排列
youzanItemSearchParams.setOrderBy("update_time:desc");
// 仅查询在售商品
youzanItemSearchParams.setShowSoldOut(0);
// 设置页码和每页数量
youzanItemSearchParams.setPageNo(1);
youzanItemSearchParams.setPageSize(20);
YouzanItemSearch youzanItemSearch = new YouzanItemSearch();
youzanItemSearch.setAPIParams(youzanItemSearchParams);
YouzanItemSearchResult result = client.invoke(youzanItemSearch);
```
### 5.2 响应示例 (JSON - 全量结构截取)
```json
{
"success": true,
"code": 200,
"message": "successful",
"data": {
"count": 18,
"items":[
{
"item_id": 365112687,
"alias": "2x9272j7pmw9q",
"title": "一袋薯片",
"price": 1000,
"origin": "15.00",
"item_no": "spbm001",
"quantity": 66,
"actual_quantity": "66",
"item_type": 0,
"channel": 0,
"post_type": 2,
"post_fee": 0,
"created_time": "2019-03-24 11:04:24",
"update_time": "2019-03-25 11:04:24",
"has_multi_sku": false,
"group_names":["零食", "全部分组"],
"image": "https://img.yzcdn.cn/upload_files/2019/03/08/Foya.jpg",
"page_url": "packages/goods/detail/index",
"delivery_template": {
"delivery_template_id": 658289,
"delivery_template_name": "杭州市按件计费",
"delivery_template_fee": "16.00",
"delivery_template_valuation_type": 1
},
"item_imgs":[
{
"id": 365112687,
"url": "https://img.yzcdn.cn/upload_files/2019/03/08/Foya.jpg",
"thumbnail": "https://img.yzcdn.cn/upload_files/2019/03/08/Foya.jpg?imageView2/2/w/290/h/290"
}
],
"properties": {
"publics":[
{
"pro_id": 123,
"pro_name": "品牌",
"val_names": ["乐事"]
}
],
"privates":[
{
"pro_id": 124,
"pro_name": "口味",
"val_names": ["番茄味"]
}
]
}
}
]
}
}
```
---
## 6. ⚠️ 常见使用问题与避坑指南
1. **接口查询状态限制(无法查全库)**
注意:此接口只能查到状态为 **“销售中”** 以及 **“已售罄”** 的商品。它**不支持**查询“仓库中”的商品。若需对全店商品库对账,需搭配 `youzan.items.inventory.get` 使用;如果是连锁总部的商品库,需使用 `youzan.item.common.search`。
2. **库存数量的陷阱 (`quantity` vs `actual_quantity`)**
当您的店铺属于“门店渠道”(`channel = 1`) 时,旧版的 `quantity` 字段因为单位换算问题,会被强制放大 **1000倍**(如实际库存 66 个会返回 66000。**强烈建议开发者在业务逻辑中直接取用 `actual_quantity` 字段**,该字段官方已自动处理好了单位转换,始终代表真实的物理库存数量。
3. **分页与查询深度的限制**
接口严格限制 `page_size * page_no <= 4000` 条。如果店铺内商品总数超过 4000 个,无法仅通过增加页码拉取全量数据。**解决方案**:利用 `update_time_start``update_time_end` 进行时间切片例如每次查询1个月内的更新记录循环遍历全量数据。
4. **时间戳单位是“毫秒”**
请仔细核对您的入参。`update_time_start``update_time_end` 必须传递 **13 位毫秒级时间戳**,如果后端语言默认生成的是 10 位秒级时间戳,会导致无法匹配数据返回空。
5. **增量更新推荐WebHook 推送**
如果您需要对店铺内的商品信息进行双向准实时同步,官方不推荐使用本接口进行“定时高频死循环轮询”。正确的姿势是:订阅有赞云商品变更消息推送(如 `ITEM_UPDATE``ITEM_CREATE`),在接收到商品变更的 `item_id` 后,再针对该单品去调用接口更新本地数据库。

View File

@@ -0,0 +1,69 @@
# 查询客户详细信息 (youzan.scrm.customer.detail.get.1.0.1)
## 1. 接口基本信息
* **API 名称**:查询客户详细信息
* **API 接口名**`youzan.scrm.customer.detail.get`
* **版本号**`1.0.1`
* **接口描述**:用于精确查询单个客户在店铺/网点内的详细视图数据。通过指定查询模块,可以一次性获取客户的基础资料、标签、积分、会员等级、储值、权益卡以及自定义属性等全方位数据。
---
## 2. 请求说明
* **请求协议**HTTPS
* **请求方式****POST** (严格要求,推荐 `application/json` 格式)
* **请求地址**
```http
https://open.youzanyun.com/api/youzan.scrm.customer.detail.get/1.0.1
```
---
## 3. 请求参数
在请求的 Body 中传入以下 JSON 数据参数:
| 参数名称 | 类型 | 是否必填 | 示例值 | 参数说明 |
| :--- | :--- | :---: | :--- | :--- |
| `access_token` | String | **是** | `9b510e232...` | 有赞云 API 鉴权凭证 |
| `yz_open_id` | String | 否 | `hijaMaXo65...` | 客户在有赞体系内的唯一身份标识。**(推荐使用,与 `account_info` 至少二选一)** |
| `account_info` | Object | 否 | - | 账户信息对象(如果不传 `yz_open_id`,可通过此字段检索如手机号匹配的客户) |
| `└─ account_id` | String | 否 | `13800138000` | 账号标识,如具体的手机号码 |
| `└─ account_type`| Integer| 否 | `2` | 账号类型,`2` 代表国内手机号 |
| `fields` | String | 否 | `user_base,credit` | **指定需要返回的客户信息模块**,多个用英文逗号分隔。<br>支持:`user_base`(基础资料), `tags`(标签), `benefit_cards`(权益卡), `benefit_level`(会员等级), `benefit_rights`(权益), `credit`(积分), `behavior`(行为数据), `giftcard`(礼品卡), `prepaid`(储值), `coupon`(优惠券), `level`(成长值/等级), `auth_info`(授权信息), `customer_attrInfos`(自定义属性) |
| `is_do_ext_point`| Boolean| 否 | `false` | 是否触发外部积分扩展点查询校验,默认为 `false` |
---
## 4. 响应参数
响应主体包裹在 `data` 中。因为该接口是**按需返回**的(基于 `fields` 传入的模块名),以下整理全量核心业务模块的响应结构:
| 根级参数名称 | 类型 | 描述说明 |
| :--- | :--- | :--- |
| `success` | Boolean | 请求是否成功 (`true`/`false`) |
| `code` | Integer | 状态码,`200` 表示成功 |
| `message` | String | 成功或错误提示信息 |
| `data` | Object | **客户详情数据总包**(内含根据 fields 指定返回的各个业务模块数据) |
### 4.1 `data` 内部核心业务模块解构
| 内部模块结构 / 数组 | 核心包含内容与说明 |
| :--- | :--- |
| **`user_base`**<br>*(基础资料)* | 客户的核心基础信息。<br>`yz_open_id`(有赞唯一标识); `name`(客户姓名/昵称); `gender`(性别:0未知,1男,2女); `birthday`(生日); `mobile` / `phone`(手机号,通常含脱敏星号); `remark`(商家后台备注); `created_at`(成为本店客户的时间); `ascription_kdt_id`(归属店铺/网点ID) |
| **`tags`** (Array)<br>*(标签信息)* | 客户被打上的标签列表。<br>`tag_id`(标签ID); `tag_name`(标签名称) |
| **`level_infos`** (Array)<br>*(等级信息)* | 从 `level` 或 `benefit_level` 调取出的客户当前等级情况。<br>`level_alias`(等级名称,如白银会员); `level_type`(等级类型:免费/付费等) |
| **`cards`** (Array)<br>*(权益卡/礼品卡)*| 返回客户名下持有的卡信息。<br>`card_alias_no`(卡号); `card_type`(卡类型); `name`(卡名称) |
| **`rights`** (Array)<br>*(会员权益)* | 包含客户享有的具体权益ID、权益名称如包邮、专享折扣等细则项 |
| **`credit`**<br>*(积分信息)* | `current_credit`(客户当前可用积分余额); `total_credit`(历史累计总积分) |
| **`behavior`**<br>*(行为/交易数据)*| `trade_count`(客户本店累计交易笔数); `trade_amount`(累计交易总金额); `last_trade_time`(最近一次交易时间) |
| **`customer_attrInfos`** (Array)<br>*(自定义属性)* | 商家在后台为客户模板设置的自定义扩展项。<br>`attr_id`(属性项ID); `attr_name`(属性项名称); `attr_value`(属性值) |
---
## 5. ⚠️ 开发者防坑指南与注意事项
1. **请求方式的陷阱(必需 POST**
虽然 API 接口命名是以 `.get` 结尾(`youzan.scrm.customer.detail.get`),但是该接口**严格要求使用 HTTP POST** 并在 Body 中传递 JSON。如果错误地使用了 GET 方法且在 URL 后拼接参数,接口会报错 `invalid params` 或导致返回的数组模块全部为空(如 `"tags":

View File

@@ -0,0 +1,111 @@
# 分页查询库存模式 (youzan.retail.open.stock.mode.query.1.0.0)
## 1. 接口基本信息
* **API 名称**:分页查询库存模式
* **API 接口名**`youzan.retail.open.stock.mode.query`
* **版本号**`1.0.0`
* **计费规则**:基础接口,调用计费
* **接口描述**:用于连锁总部或单店商家,分页查询旗下各个店铺/门店kdt_id对应的“库存管理模式”。帮助开发者判断门店是使用独立销售库存还是共享总部/仓储的库存,以及是否开启了单据管理库存。
---
## 2. 请求说明
* **请求协议**HTTPS
* **请求方式**POST (请求体传参,推荐 `application/json` 格式)
* **请求地址**
```http
https://open.youzanyun.com/api/youzan.retail.open.stock.mode.query/1.0.0
```
---
## 3. 请求参数
在请求的 Body 中传入以下 JSON 参数:
| 参数名称 | 类型 | 是否必填 | 示例值 | 参数说明 |
| :--- | :--- | :---: | :--- | :--- |
| `access_token` | String | **是** | `9b510e23...` | 鉴权凭证,用于请求 API |
| `retail_source` | String | **是** | `YOUZAN` | 零售调用来源(调用方和有赞约定的值,通常为接入应用标识,必填字段) |
| `node_kdt_id` | Long | 否 | `18938611` | 连锁场景下查询指定分店的 `kdt_id`。查总部下全部店铺则不用传;单店场景不用传。 |
| `stock_mode` | Integer | 否 | `2` | 按特定库存模式筛选。不传则查全部模式。<br>`1`-独立销售库存;`2`-共享总部库存;`3`-共享门店仓库存库;`4`-进出存单据管理。 |
| `page_no` | Integer | 否 | `1` | 页码,默认从 1 开始 |
| `page_size` | Integer | 否 | `20` | 分页大小,默认每页 20 条数据 |
---
## 4. 响应参数
响应主体包裹在 `data` 中,返回门店库存模式列表与分页信息。
| 根级参数名称 | 类型 | 描述说明 |
| :--- | :--- | :--- |
| `success` | Boolean | 请求是否成功 (`true`/`false`) |
| `code` | Integer | 状态码,`200` 表示成功,`-100` 表示系统错误 |
| `message` | String | 成功或错误提示信息 |
| `request_id` | String | 请求 ID官方标注已作废 |
| `data` | Object | **查询结果总包** |
### 4.1 `data` 内部核心业务字段解构
| 内部模块结构 / 数组 | 类型 | 核心包含内容与说明 |
| :--- | :--- | :--- |
| **`open_stock_mode_search_d_t_o`** | List | **库存模式明细列表** |
| └─ `kdt_id` | Long | 店铺/门店底层 ID 标识 |
| └─ `team_name` | String | 店铺/门店名称(如:测试门店) |
| └─ `shop_role` | Integer | 店铺类型。`2`-门店;`3`-独立仓;`6`-分销供货商店铺;`7`-前置仓 |
| └─ `stock_mode` | Integer | **库存模式**。<br>`1`-独立销售库存<br>`2`-共享总部库存<br>`3`-共享门店仓库存库<br>`4`-进出存单据管理 |
| └─ `order_manager_stock` | Integer | 单据管理库存开关(门店专用)。`1`-开启,`0`-关闭 |
| **`paginator`** | Object | **分页信息对象** |
| └─ `page` | Integer | 当前页码 |
| └─ `page_size` | Integer | 当前页大小 |
| └─ `total_count` | Integer | 匹配到的总条数(用于前端计算总页数) |
---
## 5. 响应示例 (JSON)
### 5.1 成功响应示例
```json
{
"trace_id": "yz7-0ae82d0e-1677750929750-364981",
"code": 200,
"success": true,
"message": "successful",
"data": {
"open_stock_mode_search_d_t_o":[
{
"kdt_id": 18938611,
"team_name": "YZ-零售单店(别改名字)鄧",
"shop_role": 0,
"stock_mode": 3,
"order_manager_stock": 1
}
],
"paginator": {
"total_count": 1,
"page": 1,
"page_size": 20
}
}
}
```
---
## 6. ⚠️ 开发者防坑指南与注意事项(核心排错)
1. **必传参数 `retail_source` 遗漏阻断**
有赞零售Retail体系的接口有部分老业务逻辑强依赖 `retail_source`(零售调用来源)。此字段在文档中明确标注为**必填(是)**。如果不传,接口会直接报错拦截。如果您是常规自用型应用对接,通常约定传 `"YOUZAN"` 或咨询有赞服务小二获取专属标识。
2. **错误码 `-100` 的核心排查方向Token 权限越界)**
如果接口返回 `{"code": -100, "message": "服务器错误"}`,除了检查必填参数外,**最常见的原因是 `access_token` 的授权主体不匹配**。
* **排查建议**:请检查申请 Token 的授权 ID 是否是连锁“总部”、“单店”或“合伙人”级别的 ID。例如用一个无下属门店的普通微商城 Token 试图去拉取总部结构,就会触发越界报错。
3. **`node_kdt_id` 与网点架构关系**
* **单店系统**:不要传 `node_kdt_id`。
* **连锁总部系统**:如果需要一次性获取旗下所有门店的模式(常用于 ERP 系统初始化对账),不传此参数即可查全部;如果由于业务变动需要校验某一家具体分店是否切换了共享仓,传入该分店的 `kdt_id` 精确点查,效率最高。
4. **`stock_mode` 共享库存拦截提醒**
当 `stock_mode` 返回值为 `2`(共享总部库存)时,意味着该门店没有独立的实物库存管理权限。此时如果您通过代码强行调用门店库存扣减/入库的 API有赞底层会报错拦截。业务逻辑上必须将库存扣减的指令路由指向其**共享总部**的店铺 ID 或总仓。

View File

@@ -0,0 +1,76 @@
# 查询仓库商品库存 (youzan.retail.open.query.warehousestock.1.0.0)
## 1. 接口基本信息
* **API 名称**:查询仓库商品库存
* **API 接口名**`youzan.retail.open.query.warehousestock`
* **版本号**`1.0.0`
* **接口描述**用于精确查询指定仓库或线下门店下特定商品规格SKU的各项详细库存数据。支持实时拉取实物库存、在途库存、计划库存及各维度的占用库存。
---
## 2. 请求说明
* **请求协议**HTTPS
* **请求方式**POST (严格要求,推荐 `application/json` 格式)
* **请求地址**
```http
https://open.youzanyun.com/api/youzan.retail.open.query.warehousestock/1.0.0
```
---
## 3. 请求参数
在请求的 Body 中传入以下 JSON 参数:
| 参数名称 | 类型 | 是否必填 | 示例值 | 参数说明 |
| :--- | :--- | :---: | :--- | :--- |
| `access_token` | String | **是** | `9b510e232...` | 有赞云 API 鉴权凭证 |
| `warehouse_code` | String | **是** | `MD00021` | 仓库/门店编码。必须传递有赞底层生成的仓编码(由 `youzan.retail.open.warehouse.query` 接口获取)。 |
| `sku_codes` | Array (String) | **是** | `["SKU001", "SKU002"]` | 商品规格编码SKU数组。**注意:每次请求最多传入 20 个 SKU 编码。** |
---
## 4. 响应参数
响应主体包裹在 `data` 中,以数组列表形式返回请求中对应 SKU 的多维度库存详情。
| 根级参数名称 | 类型 | 描述说明 |
| :--- | :--- | :--- |
| `success` | Boolean | 请求是否成功 (`true`/`false`) |
| `code` | Integer | 状态码,`200` 表示成功 |
| `message` | String | 成功或错误提示信息 |
| `data` | Array | **库存结果列表集合** |
### 4.1 `data` 数组内核心业务字段解构
| 内部参数名称 | 类型 | 核心说明 |
| :--- | :--- | :--- |
| `sku_code` | String | 商品规格编码(对应入参检索的 SKU。 |
| `warehouse_code`| String | 当前查询返回的仓库编码。 |
| `stock_num` | String/Decimal| **实物库存数量**。当前仓库中真实存在的物理库存总数。(注:该接口返回的库存数值精确到小数点后两位,如 `100.00` |
| `freeze_num` | String/Decimal| **实物库存占用**。已被订单锁定、但尚未真实扣减出库的物理库存数量。 |
| `plan_num` | String/Decimal| **计划库存数量**。多用于门店预售或外部系统的虚拟分配库存。 |
| `plan_freeze_num`| String/Decimal| **计划库存占用**。计划库存中已被锁定的数量。 |
| `road_num` | String/Decimal| **在途库存**。处于采购在途、调拨在途的预计入库商品数量。 |
---
## 5. ⚠️ 开发者防坑指南与注意事项(核心高频报错提醒)
1. **极其高频的报错:`234000003 - 仓库信息不存在`**
如果您在调用该接口时收到此错误,**99% 的原因是您在 `warehouse_code` 字段里错误地传入了门店的店铺 ID`kdt_id`**。
* **避坑必看**:此接口不认网点 ID。请务必先调用 `youzan.retail.open.warehouse.query`(查询总部下门店和仓库信息),拿到类似 `MD00021` 或 `TEST001` 的真正**仓库编码**,再传入本接口使用。
2. **如何计算“当前可用库存”?**
本接口不会直接吐出一个叫“可用库存”的字段。如果您正在对接前端商城的剩余可售数量计算,请按照公式自行相减:
`可用库存 = 实物库存 (stock_num) - 实物库存占用 (freeze_num)`
3. **参数数量超限限制**
入参的 `sku_codes` 数组**严格限制最大长度为 20**。如果您需要进行全店全量商品的库存对账或初始化同步,请不要用该接口循环遍历(极易触发限流告警),建议使用对应的“分页查询库存”专用接口。此接口更适合购物车结算前或单品详情页的**点对点实时余量校验**。
4. **精度格式说明**
有赞新零售体系内的库存不仅支持整数,也支持散装/称重商品(如按斤、米售卖)。因此返回的 `stock_num` 等数量字段可能为带有两位小数的数值型字符串(如 `"5.50"`),在强类型语言(如 Java、Go解析时需注意浮点数/高精度类的映射处理。

View File

@@ -0,0 +1,76 @@
# 查询总部下门店和仓库信息 (youzan.retail.open.warehouse.query.3.0.0)
## 1. 接口基本信息
* **API 名称**:查询总部下门店和仓库信息
* **API 接口名**`youzan.retail.open.warehouse.query`
* **版本号**`3.0.0`
* **接口描述**:用于连锁总部或多网点商家,批量查询其底下的所有真实仓库以及具有仓库属性的“线下门店”信息。获取到的 `warehouse_code` 是后续调用库存调整、盘点、调拨等单据接口的**必传核心参数**。
---
## 2. 请求说明
* **请求协议**HTTPS
* **请求方式**POST (推荐 `application/json` 格式)
* **请求地址**
```http
https://open.youzanyun.com/api/youzan.retail.open.warehouse.query/3.0.0
```
---
## 3. 请求参数
在请求的 Body 中传入以下 JSON 参数:
| 参数名称 | 类型 | 是否必填 | 示例值 | 参数说明 |
| :--- | :--- | :---: | :--- | :--- |
| `access_token` | String | **是** | `9b510e232...` | 有赞云 API 鉴权凭证(请使用连锁总店的 Token 授权) |
| `page_no` | Integer | 否 | `1` | 页码,默认从 1 开始 |
| `page_size` | Integer | 否 | `20` | 每页返回的数量,建议默认 20最大通常不超过 100 |
| `warehouse_name` | String | 否 | `北京一号仓` | 仓库或门店名称(支持模糊搜索筛选) |
| `retail_source` | String | 否 | `YOUZAN` | 零售调用来源。*(注:该字段官方已于 2021-04-26 废弃取消强校验,新接入开发者无需传值,不影响调用)* |
---
## 4. 响应参数
响应主体包裹在 `data` 中,主要返回门店与仓库的映射列表信息。
| 根级参数名称 | 类型 | 描述说明 |
| :--- | :--- | :--- |
| `success` | Boolean | 请求是否成功 (`true`/`false`) |
| `code` | Integer | 状态码,`200` 表示成功 |
| `message` | String | 成功或错误提示信息 |
| `data` | Object | **查询结果总包** |
| └─ `count` | Integer | 匹配到的总条数(用于计算分页) |
| └─ `items` | Array | **仓库/门店信息列表集合** |
### 4.1 `items` 数组内核心业务字段解构
| 内部参数名称 | 类型 | 核心说明 |
| :--- | :--- | :--- |
| `warehouse_code` | String | **仓库/门店编码(核心参数)**。有赞系统内部生成的唯一编码(如 `MD00021` 或 `TEST001`)。 |
| `warehouse_name` | String | 仓库或门店的名称。 |
| `warehouse_type` | Integer | 仓库属性类型。区分该主体是“普通大仓”还是“线下实体门店”。 |
| `kdt_id` | Long | 绑定的对应店铺/网点的底层 ID。如果该仓是独立门店则返回对应门店的 kdt_id |
| `status` | Integer | 仓库状态。例如正常营业、停用等。 |
| `address` | String | 仓库或门店的详细物理地址。 |
---
## 5. ⚠️ 开发者防坑指南与注意事项(核心高频报错提醒)
1. **极其容易混淆的 `warehouse_code` 与 `kdt_id`(错误码 `234000003`**
在有赞新零售/连锁系统中,“线下门店”本身也被视作一个“仓库”。很多开发者在调用“查询/调整门店商品库存”接口(如 `youzan.retail.open.query.warehousestock` 或 `youzan.retail.open.stock.adjust`)时,**错误地将门店的底层店铺ID`kdt_id`)当作仓库编码传了进去**,这会导致接口直接报错:`{"code": 234000003, "message": "仓库信息不存在"}`。
**正确做法**:必须先调用本接口(`youzan.retail.open.warehouse.query`),拿到门店对应的真实 `warehouse_code`(类似 `MD00021`),再用这个编码去调用库存接口。
2. **ERP 映射关系的建立(库存对接必读)**
如果您正在对接第三方 ERP 系统的网店/门店库存同步,正确的初始化流必须是:
在第三方系统中建立映射表 → 调用本接口全量拉取有赞的 `warehouse_code` → 与 ERP 系统里的仓库编码(或者门店编号)进行 `1:1` 的绑定映射。
3. **连锁授权架构提示**
本接口属于“零售连锁/多网点”场景下的专属 API。调用时使用的 `access_token` 必须是由**总部(总店)**授权产生的。如果使用单店或无多网点权限的微商城 Token 请求,可能会拉取为空或提示权限不足。

View File

@@ -0,0 +1,105 @@
# 查询门店商品信息 (youzan.retail.open.offline.spu.query.3.0.0)
## 1. 接口基本信息
* **API 名称**:查询门店商品信息
* **API 接口名**`youzan.retail.open.offline.spu.query`
* **版本号**`3.0.0`
* **计费规则**:基础接口,调用计费
* **接口描述**:查询门店商品信息。**注意:系统有严格的查询深度限制,`page_no * page_size` 的乘积不能大于 3300。**
---
## 2. 请求说明
* **请求协议**HTTPS
* **请求方式**POST (请求体传参,`application/json` 格式)
* **请求地址**
```http
https://open.youzanyun.com/api/youzan.retail.open.offline.spu.query/3.0.0
```
---
## 3. 请求参数
| 参数名称 | 类型 | 是否必填 | 示例值 | 参数说明 |
| :--- | :--- | :---: | :--- | :--- |
| `access_token` | String | **是** | `9b510e232...` | 鉴权凭证,用于请求 API |
| `retail_source` | String | **是** | `YOUZAN` | 零售调用来源。*(注该字段已于2021-04-26号废弃取消校验新接入开发者无需传值已对接开发者不受影响)* |
| `warehouse_code` | String | 否 | `TEST001` | **仓库编码**。可通过 `youzan.retail.open.warehouse.query` 接口获取;**如果不填,默认查总部商品库商品** |
| `show_display` | Integer | 否 | `0` | 销售状态。`0`:已售罄;`1`:销售中;`2`:在库中。**不传值默认查询“销售中”状态** |
| `page_no` | Integer | 否 | `1` | 页码,从 1~300 开始的正整数。(`page_no * page_size` 总数不超过3300) |
| `page_size` | Integer | 否 | `20` | 每页条数,默认 20 条。(`page_no * page_size` 总数不超过3300) |
| `name_or_sku_no` | String | 否 | `商品1` | 搜索条件:商品名称 / 商品条码 |
| `item_ids` | List<Long> | 否 | `[12312321]` | 商品 Id 列表(适用于首次通过该接口批量获取存入数据库,后续查询时取出导入的场景) |
---
## 4. 响应参数
响应主体包裹在 `data` 中,主要分为 `paginator` (分页信息) 和 `offline_spus` (门店商品明细)。
| 根级参数名称 | 类型 | 描述说明 |
| :--- | :--- | :--- |
| `success` | Boolean | 表示本次请求是否成功 (`true`/`false`) |
| `code` | Integer | 网关返回码,`200` 表示成功 |
| `message` | String | 网关返回码描述 |
| `data` | Object | **响应参数总包** |
| └─ `paginator` | Object | **分页数据对象** |
| &nbsp;&nbsp;&nbsp;&nbsp;├─ `page` | Integer | 页码,从 1 开始正整数 |
| &nbsp;&nbsp;&nbsp;&nbsp;├─ `page_size` | Integer | 每页条数。默认 20 条 |
| &nbsp;&nbsp;&nbsp;&nbsp;└─ `total_count` | Integer | 匹配到的总条数 |
| └─ `offline_spus`| List | **门店商品 (SPU) 信息列表** |
### 4.1 `offline_spus` (门店商品明细) 与 `sku_models` (规格明细) 结构解构
| 内部参数名称 | 类型 | 核心说明 |
| :--- | :--- | :--- |
| `item_id` | Long | 门店商品 Id有赞生成的店铺下商品唯一 Id |
| `title` | String | 商品名称 |
| `spu_no` | String | 商品条码 |
| `price` | String | 零售价 |
| `max_guide_price`| String | 最大建议零售价(单位:元) |
| `min_guide_price`| String | 最小建议零售价(单位:元) |
| `unit` | String | 单位(如:件) |
| `sell_stock_count`| String | 销售库存 |
| `sold_num` | String | 总销量 |
| `created_at` | String | 创建时间 |
| `is_display` | Integer | 是否上架:`1` (上架)`0` (未上架) |
| `measurement` | Integer | 称重非称重商品标识:`0` (非称重)`10` (称重) |
| `has_multi_sku` | Boolean | 是否多规格:`true` (是)`false` (否) |
| `is_non_spec` | Boolean | 是否无规格:`true` (是)`false` (否) |
| `category_name` | String | 商品分组名称 |
| `specifications` | String | 规格信息(如:`"XL"` |
| `tax_code` | String | 税收分类编码(商家配置开具电子发票时自定义填写) |
| `plu_code` | String | PLU 码 |
| `biz_mark_code` | String | 商品标编码(如 `"010000000002"` |
| `biz_mark_name` | String | 商品标名称。枚举对应:<br>`"010000000002"`:预售商品<br>`"020000000001"`:零售连锁<br>`"000000000041"`:餐饮商品<br>`"010000000042"`:生鲜果蔬商品<br>`"010000000043"`:海淘跨境商品 |
| **`sku_models`** | List | **门店商品 SKU (规格) 信息列表** |
| &nbsp;&nbsp;├─ `sku_id` | Long | 规格 Id有赞生成的店铺下商品规格唯一 Id |
| &nbsp;&nbsp;├─ `sku_no` | String | 规格条码(支持商家自定义,英文数字组合,店铺下唯一) |
| &nbsp;&nbsp;├─ `specs` | String | 规格信息(如:`"红色XL"` |
| &nbsp;&nbsp;├─ `price` | String | 价格 |
| &nbsp;&nbsp;├─ `max_guide_price`| String | 最大建议零售价 |
| &nbsp;&nbsp;├─ `min_guide_price`| String | 最小建议零售价 |
| &nbsp;&nbsp;├─ `plu_code` | String | PLU 码 |
| &nbsp;&nbsp;└─ `unit` | String | 单位 |
---
## 5. ⚠️ 开发者防坑指南与注意事项(基于官方文档)
1. **严格的深度查询限制 (`3300` 限制)**
文档特别强调了 Elasticsearch 的搜索深度约束:`page_no * page_size` 的**乘积绝对不能超过 3300**。例如当 `page_size` 为默认的 20 时,最多只能翻到第 165 页。如果要查询的数据量庞大,切忌无脑通过递增页码扫库,以免触发限制报错。
2. **`warehouse_code`(仓库编码)的归属逻辑**
* 如果您**不传**此参数:接口默认只去查询“总部商品库”的商品信息。
* 如果您想要查询某家特定门店的下发商品与价格信息,必须传入正确的、通过 `youzan.retail.open.warehouse.query` 获取到的特定门店仓库编码(如 `TEST001`)。
* **报错预警:**如果传入了错误或不存在的编码,系统将返回错误码 `234007003` (不合法的仓库编码)。
3. **`show_display` 默认行为过滤**
请注意,如果请求时不传 `show_display` 参数,接口底层会自动帮您**过滤掉**下架在库和已售罄的商品,**只返回状态为“1销售中”的商品**。如果有盘库需求,需要找回仓库里的下架商品,请务必显式传入对应的状态值。
4. **废弃的 `retail_source` 字段**
官方已明确声明该字段自 2021-04-26 起已取消校验。新接入的开发者无需关心或给该参数传值,不会影响接口的正常调用。
5. **查询结果为空处理 (`234002001` 错误码)**
如果在传入 `name_or_sku_no` 后没有匹配到任何结果,接口会返回错误码 `234002001`(无商品信息)。在代码侧编写逻辑时,需对此错误码做特殊的“无数据”放行处理,而不是作为异常直接抛出。

View File

@@ -0,0 +1,109 @@
# 查询售后单列表 (youzan.trade.refund.search.3.0.1)
## 1. 接口基本信息
* **API 名称**:查询售后单列表
* **API 接口名**`youzan.trade.refund.search`
* **版本号**`3.0.1`
* **计费规则**:基础接口,调用计费
* **接口描述**:用于按条件(如时间范围、订单号、状态等)分页查询店铺内的退款及售后单据列表。**注意:如果入参没有设置查询时间范围,接口默认查询创建时间为近 3 个月的单据。**
---
## 2. 请求说明
* **请求协议**HTTPS
* **请求方式**POST (请求体传参,`application/json` 格式)
* **请求地址**
```http
https://open.youzanyun.com/api/youzan.trade.refund.search/3.0.1
```
---
## 3. 请求参数
在请求体Body中传入以下 JSON 参数:
| 参数名称 | 类型 | 是否必填 | 参数说明 |
| :--- | :--- | :---: | :--- |
| `access_token` | String | **是** | 鉴权凭证,用于请求 API |
| `tid` | String | 否 | 有赞订单号E开头+年月日时分秒+随机数长24位 |
| `refund_id` | String | 否 | 售后退款单号 |
| `yz_open_id` | String | 否 | **(3.0.1新增)** 有赞用户 ID买家在有赞的唯一身份标识 |
| `create_time_start`| Long | 否 | 创建起始时间,**Unix时间戳单位**。不传则默认近3个月 |
| `create_time_end` | Long | 否 | 创建截止时间,**Unix时间戳单位** |
| `update_time_start`| Long | 否 | 更新起始时间,**Unix时间戳单位** |
| `update_time_end` | Long | 否 | 更新截止时间,**Unix时间戳单位** |
| `page_no` | Integer | 否 | 分页数,**`page_no > 100` 会报错“查询页数超过100”** |
| `page_size` | Integer | 否 | 每页显示个数,**`page_no * page_size > 3000` 会报错“超过ES搜索最大深度”** |
| `status` | String | 否 | 退款状态:<br>`WAIT_SELLER_AGREE`: 等待卖家同意<br>`WAIT_BUYER_RETURN_GOODS`: 等待买家退货<br>`WAIT_SELLER_CONFIRM_GOODS`: 等待卖家确认收货<br>`SELLER_REFUSE_BUYER`: 卖家拒绝退款<br>`CLOSED`: 退款关闭<br>`SUCCESS`: 退款成功<br>`CUSTOMER_SERVICE_IN`: 客满介入<br>`SELLER_REFUSE_BUYER_RETURN_GOODS`: 卖家拒绝收货,待买家处理<br>`SELLER_RETURN_GOODS`: 商家确认收货并发送换货 |
| `type` | Integer | 否 | 退款流程类型:`1`(买家申请退款), `2`(商家主动退款), `3`(一键退款), `4`(零售门店换货), `5`(微商城换货) |
| `demand` | Integer | 否 | 退款诉求:`1`(仅退款), `2`(退货退款), `3`(换货) |
| `phase` | Integer | 否 | 退款阶段:`1`(售中), `2`(售后) |
| `refund_mode` | Integer | 否 | 退款资金类型:`0`(原路退), `1`(现金退), `2`(标记退) |
| `search_tag` | Integer | 否 | 退款单列表查询 Tag`0`(待商家处理), `1`(待买家处理), `2`(客服介入中) |
| `delivery_status` | Integer | 否 | 发货状态:`1`(未发货), `2`(已发货) |
| `sku_no` | String | 否 | 商品规格编码 |
| `goods_title` | String | 否 | 商品名称(支持模糊搜索) |
| `delivery_no` | String | 否 | 物流单号 |
| `node_kdt_id` | Long | 否 | 分店店铺 ID连锁场景使用 |
| `sale_way` | Integer | 否 | 销售渠道:`1`(门店), `2`(网店) |
| `return_stock_status`| Integer | 否 | 退货归还库存状态:`0`(无需入库), `10`(待入库), `20`(归还库存成功) |
| `cs_status` | Integer | 否 | 客服介入状态:`1`(不需要), `2`(需要), `3`(介入结束) |
| `buyer_phone` | String | 否 | 买家电话 |
| `invalid` | Integer | 否 | 是否为无效的退款单:`1`(true无效), `0`(false有效) |
| `pay_type` / `pay_way` | Integer | 否 | 支付类型与支付渠道(如微信、支付宝、储值卡等细分枚举,详见官方码表) |
---
## 4. 响应参数
响应主体包裹在 `data` 中,返回当前搜索条件下的退款单总数和详情列表。
| 根级参数名称 | 类型 | 描述说明 |
| :--- | :--- | :--- |
| `success` | Boolean | 请求是否成功 (`true`/`false`) |
| `code` | String / Int | 成功失败状态码,`200` 表示成功 |
| `message` | String | 成功或错误提示信息 |
| `data` | Object | **查询结果总包** |
| └─ `total` | Integer | 匹配到的单据总数 |
| └─ `refunds` | Array | **退款信息明细列表** |
### 4.1 `refunds` 数组内核心业务字段解构
| 内部参数名称 | 类型 | 核心说明 |
| :--- | :--- | :--- |
| `refund_id` | String | 退款 ID / 售后单号 |
| `tid` | String | 关联的有赞订单号 |
| `kdt_id` | Long | 产生该单据的店铺 ID |
| `node_kdt_id` | Long | 产生该单据的子店/网点 ID |
| `status` | String | 退款状态。枚举同请求入参 `status` |
| `return_goods` | Boolean | 是否退货 |
| `refund_fee` | String | 申请退款的金额(如 `"0.01"` 元) |
| `reason` | Integer | 退款原因代码。<br>常见:`54`(未按约定时间发货), `11`(质量问题), `104`(拍错/不喜欢) 等。系统退款:`204`(订单关闭), `208`(拼团未成团) 等。 |
| `created` | String | 退款申请创建时间(格式 `"yyyy-MM-dd HH:mm:ss"` |
| `modified` | String | 退款申请最后修改时间(格式 `"yyyy-MM-dd HH:mm:ss"` |
| `cs_status` | Integer | 客满介入状态:`1`(客满未介入)`2`(客满介入中) |
| `delivery_status` | Integer | 发货状态:`1`(未发货)`2`(已发货) |
---
## 5. ⚠️ 开发者防坑指南与注意事项(核心对账与排错必看)
1. **版本升级核心变化 (`yz_open_id` 替代 `buyer_id`)**
`3.0.1` 最新版本中,废弃了原有的 `buyer_id`,全面拥抱 **`yz_open_id`** 作为用户的全网唯一标识。在进行 CRM 对账或跨接口(如用户详情查询接口)数据互通时,必须使用 `yz_open_id`
2. **时间戳参数单位陷阱(必须是秒)**
有赞的大多数接口时间戳为 13位毫秒级但是**该版本接口明确标注 `create_time_start` / `update_time_end` 等字段的单位为10位**
如果错误传入了 13 位时间戳,会导致报错或过滤结果为空。
3. **ES 搜索引擎的深度翻页限制(报错码 53002**
受限于底层 Elasticsearch 架构,请严格遵守以下分页限制,否则会报错 `page_size type error` 或导致接口熔断:
* **`page_no` 不能大于 100**。
* **`page_no * page_size` 乘积不能超过 3000**。
* **排错方案**:大店单据多时,**绝不可用大分页数无脑遍历**。请按更新时间将范围切分得更细(如每次只查 1 小时的数据),来保持总条数低于限制阈值。
4. **单据默认时间范围3 个月内)**
如果您在请求时不加任何时间限制直接查询,系统会自动添加默认过滤条件,仅返回最近 3 个月创建的退款单。对于旧账对账或初始化历史数据同步,务必显式传入准确的时间范围边界。
5. **网点/门店单据的隔离(`node_kdt_id`**
如果是连锁商家总部进行数据拉取,通过总部的 Token 默认只能拉取总部的单据。必须通过循环传递 `node_kdt_id` 才能精准拉取到各分店发生的维权与退款记录,切勿遗漏导致财务对账不平。

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"
}

View File

@@ -0,0 +1,20 @@
-- ================================================
-- 商品表 price 字段迁移DECIMAL(12,2) → BIGINT
-- 执行前提:已有数据的 price/cost_price 含小数点(如 10.00
-- 执行顺序:先改类型,再清除已入库的错误数据让重新同步
-- ================================================
USE youzandatahub;
-- Step 1: 修改字段类型 DECIMAL → BIGINT
ALTER TABLE `dim_item_sku`
MODIFY COLUMN `price` BIGINT DEFAULT 0 COMMENT '当前售卖价(单位:分)',
MODIFY COLUMN `cost_price` BIGINT DEFAULT 0 COMMENT '成本价(单位:分,若有)';
-- Step 2: 清除已入库的商品数据(因为旧数据的 price 可能被错误解析)
-- 重新同步后会自动用正确的分单位入库
TRUNCATE TABLE `dim_item_sku`;
-- 验证
-- SELECT COUNT(*) FROM dim_item_sku; -- 应为 0
-- 然后调用 GET http://localhost:8080/api/sync/item 重新同步

30
sql/phase3_ddl.sql Normal file
View File

@@ -0,0 +1,30 @@
-- Phase 3 DDL Migration Script
-- 在已有数据库上执行,补充 Phase 3 新增的字段和表
-- dim_inventory_snapshot 如果已在之前执行中创建,则此脚本中的 CREATE TABLE 会报 "already exists",可忽略
USE youzandatahub;
-- 1. dim_offline_store: 新增仓库编码和库存模式字段
ALTER TABLE dim_offline_store
ADD COLUMN warehouse_code VARCHAR(64) DEFAULT NULL COMMENT '仓库编码(库存API必需)',
ADD COLUMN stock_mode INT DEFAULT NULL COMMENT '库存模式:1独立,2共享总部,3共享门店仓,4进出存';
-- 2. dwd_trade_refund_detail: 新增退款诉求、阶段、买家ID、修改时间
ALTER TABLE dwd_trade_refund_detail
ADD COLUMN refund_demand INT DEFAULT NULL COMMENT '退款诉求:1仅退款,2退货退款,3换货',
ADD COLUMN refund_phase INT DEFAULT NULL COMMENT '退款阶段:1售中,2售后',
ADD COLUMN yz_open_id VARCHAR(64) DEFAULT NULL COMMENT '买家yz_open_id',
ADD COLUMN modified_time DATETIME DEFAULT NULL COMMENT '最后修改时间';
-- 3. 新建库存快照表 (如已存在则跳过)
CREATE TABLE IF NOT EXISTS `dim_inventory_snapshot` (
`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`warehouse_code` VARCHAR(64) NOT NULL COMMENT '仓库编码',
`sku_code` VARCHAR(128) NOT NULL COMMENT 'SKU编码',
`stock_num` DECIMAL(12,2) DEFAULT 0 COMMENT '实物库存',
`freeze_num` DECIMAL(12,2) DEFAULT 0 COMMENT '实物占用',
`available_num` DECIMAL(12,2) DEFAULT 0 COMMENT '可用库存(stock_num-freeze_num)',
`snapshot_date` DATE NOT NULL COMMENT '快照日期',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '中台同步时间',
UNIQUE KEY `uk_snapshot` (`warehouse_code`, `sku_code`, `snapshot_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日库存快照表';

View File

@@ -0,0 +1,23 @@
-- Phase 4 补丁: adm 表增加 outer_item_id 列,用于按商家统一编码聚合
-- 原因: 有赞连锁模式下,同一个商品在不同门店有不同的 item_id
-- 只有 outer_item_id (商家编码) 才是全门店统一的商品标识
-- 1. 复购表增加 outer_item_id
ALTER TABLE `adm_item_repurchase`
ADD COLUMN `outer_item_id` VARCHAR(64) DEFAULT NULL COMMENT '商家统一商品编码' AFTER `sku_id`,
ADD COLUMN `item_name` VARCHAR(128) DEFAULT NULL COMMENT '商品名称' AFTER `outer_item_id`,
DROP INDEX `uk_repurchase_date`,
ADD UNIQUE KEY `uk_repurchase_date` (`stat_date`, `outer_item_id`);
-- 2. 购物篮表增加 outer_item_id
ALTER TABLE `adm_item_basket`
ADD COLUMN `outer_item_id_a` VARCHAR(64) DEFAULT NULL COMMENT '商品A商家编码' AFTER `item_id_a`,
ADD COLUMN `outer_item_id_b` VARCHAR(64) DEFAULT NULL COMMENT '商品B商家编码' AFTER `item_id_b`,
DROP INDEX `uk_item_pair`,
ADD UNIQUE KEY `uk_item_pair` (`outer_item_id_a`, `outer_item_id_b`);
-- 3. 销售趋势表增加 outer_item_id
ALTER TABLE `adm_item_sales_trend`
ADD COLUMN `outer_item_id` VARCHAR(64) DEFAULT NULL COMMENT '商家统一商品编码' AFTER `sku_id`,
DROP INDEX `uk_trend_date`,
ADD UNIQUE KEY `uk_trend_date` (`stat_date`, `outer_item_id`);

97
sql/phase4_ddl.sql Normal file
View File

@@ -0,0 +1,97 @@
-- Phase 4: 商品与会员洞察 (ADM数据市集层)
-- ==========================================
-- 会员洞察 (Customer Insights)
-- ==========================================
-- 1. 客户 RFM 与评分模型表
CREATE TABLE IF NOT EXISTS `adm_customer_rfm` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`yz_open_id` VARCHAR(64) NOT NULL COMMENT '有赞open_id',
`mobile` VARCHAR(32) DEFAULT NULL COMMENT '客户手机号',
`last_trade_time` DATETIME DEFAULT NULL COMMENT '最近一次消费时间 (Recency)',
`trade_count_90d` INT DEFAULT 0 COMMENT '近90天消费频次 (Frequency)',
`trade_amount_90d` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '近90天消费金额 (Monetary)',
`r_score` INT DEFAULT 0 COMMENT 'R得分(1-5级)',
`f_score` INT DEFAULT 0 COMMENT 'F得分(1-5级)',
`m_score` INT DEFAULT 0 COMMENT 'M得分(1-5级)',
`rfm_group` VARCHAR(32) DEFAULT NULL COMMENT '客群分层(例如: 重要价值客户/沉睡客户等)',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '同步更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yz_open_id` (`yz_open_id`),
KEY `idx_rfm_group` (`rfm_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员RFM洞察表';
-- 2. 客户标签表 (人群特征画像)
CREATE TABLE IF NOT EXISTS `adm_customer_tags` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`yz_open_id` VARCHAR(64) NOT NULL,
`tag_name` VARCHAR(64) NOT NULL COMMENT '标签名称(如: 工作日外带客, 高客单囤货客)',
`tag_value` VARCHAR(128) DEFAULT NULL COMMENT '标签值或权重',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_customer_tag` (`yz_open_id`, `tag_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户画像标签表';
-- ==========================================
-- 商品洞察 (Product Insights)
-- ==========================================
-- 3. 商品销售趋势表 (日维统计)
CREATE TABLE IF NOT EXISTS `adm_item_sales_trend` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`stat_date` DATE NOT NULL COMMENT '统计归属日期',
`item_id` BIGINT NOT NULL COMMENT '商品ID',
`sku_id` BIGINT NOT NULL DEFAULT 0 COMMENT '规格ID',
`item_name` VARCHAR(128) DEFAULT NULL COMMENT '商品名称',
`sales_qty` INT DEFAULT 0 COMMENT '当日销量(件数)',
`sales_amount` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '当日销售额',
`order_count` INT DEFAULT 0 COMMENT '当日包含该商品的订单数',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_trend_date` (`stat_date`, `item_id`, `sku_id`),
KEY `idx_item` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品销售趋势表';
-- 4. 商品关联分析表 (购物篮挖掘)
CREATE TABLE IF NOT EXISTS `adm_item_basket` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`item_id_a` BIGINT NOT NULL COMMENT '商品A',
`item_name_a` VARCHAR(128) DEFAULT NULL COMMENT '商品A名称',
`item_id_b` BIGINT NOT NULL COMMENT '商品B',
`item_name_b` VARCHAR(128) DEFAULT NULL COMMENT '商品B名称',
`pair_order_count` INT DEFAULT 0 COMMENT 'A和B共同出现的订单数',
`item_a_order_count` INT DEFAULT 0 COMMENT 'A独立出现的包含订单总数',
`confidence` DECIMAL(5, 4) DEFAULT 0.0000 COMMENT '置信度 (A->B的概率)',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_item_pair` (`item_id_a`, `item_id_b`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品购物篮关联分析表';
-- 5. 商品复购分析表
CREATE TABLE IF NOT EXISTS `adm_item_repurchase` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`stat_date` DATE NOT NULL COMMENT '统计归属日期',
`item_id` BIGINT NOT NULL,
`sku_id` BIGINT NOT NULL DEFAULT 0,
`purchaser_count_30d` INT DEFAULT 0 COMMENT '过去30天购买总人数',
`repurchaser_count_30d` INT DEFAULT 0 COMMENT '过去30天产生二次复购的人数',
`repurchase_rate_30d` DECIMAL(5, 4) DEFAULT 0.0000 COMMENT '30天复购率 (二次人数/总人数)',
`matrix_tag` VARCHAR(32) DEFAULT NULL COMMENT '波士顿矩阵打标分类 (如: 核心引流款, 需淘汰等)',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_repurchase_date` (`stat_date`, `item_id`, `sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品复购率分析表';

354
sql/youzan_tplus1_ddl.sql Normal file
View File

@@ -0,0 +1,354 @@
-- 交易明细事实表 (dwd_trade_order_detail)
-- 用于拉取有赞 trades.sold.get 等接口数据基于订单明细粒度SKU级别
CREATE TABLE `dwd_trade_order_detail` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '底层自增主键',
`tid` VARCHAR(64) NOT NULL COMMENT '有赞主订单号 (tid)',
`oid` VARCHAR(64) NOT NULL COMMENT '有赞子订单号/明细ID (oid)',
`buyer_phone` VARCHAR(32) DEFAULT NULL COMMENT '买家手机号 (buyer_info.buyer_phone),基于安全组件可能脱敏',
`yz_open_id` VARCHAR(64) DEFAULT NULL COMMENT '有赞用户开放ID (buyer_info.yz_open_id)',
`fans_id` BIGINT DEFAULT NULL COMMENT '粉丝ID (buyer_info.fans_id)',
`shop_org_id` VARCHAR(64) DEFAULT NULL COMMENT '店铺组织ID (shop_org_id)',
`offline_id` BIGINT DEFAULT NULL COMMENT '网点/门店ID (node_kdt_id)',
`item_id` BIGINT NOT NULL COMMENT '商品ID (item_id)',
`sku_id` BIGINT NOT NULL COMMENT 'SKU ID (sku_id)',
`outer_item_id` VARCHAR(128) DEFAULT NULL COMMENT '商家外部商品编码 (outer_item_id) - 核心映射字段',
`outer_sku_id` VARCHAR(128) DEFAULT NULL COMMENT '商家外部SKU编码 (outer_sku_id)',
`title` VARCHAR(255) DEFAULT NULL COMMENT '商品名称快照 (title)',
`price` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '单商品原价 (price)',
`num` INT NOT NULL DEFAULT 0 COMMENT '购买数量 (num)',
`total_fee` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '该明细原价小计 (total_fee = price * num)',
`discount_price` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '单商品折扣后价格 (discount_price)',
`payment` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '该明细实际支付摊销金额 (payment) - 计算销售额核心',
`status` VARCHAR(32) NOT NULL COMMENT '当前主订单状态 (status: WAIT_SELLER_SEND_GOODS 等)',
`refund_state` INT DEFAULT 0 COMMENT '退款状态 (refund_state: 0正常, 其他视情况)',
`source_platform` VARCHAR(32) DEFAULT NULL COMMENT '订单来源平台 (source_info.source.platform: weixin/other)',
`is_retail_order` TINYINT(1) DEFAULT 0 COMMENT '是否零售门店订单 (order_info.is_retail_order)',
`order_type` INT DEFAULT 0 COMMENT '订单类型 (order_info.type: 0-普通 10-零售门店)',
`pay_time` DATETIME DEFAULT NULL COMMENT '支付时间 (pay_time) - 业务时间戳',
`created_time` DATETIME DEFAULT NULL COMMENT '订单创建时间 (created)',
`update_time` DATETIME DEFAULT NULL COMMENT '订单最后更新时间 (update_time)',
`success_time` DATETIME DEFAULT NULL COMMENT '订单完成时间 (success_time)',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '中台数据更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_oid` (`oid`),
KEY `idx_tid` (`tid`),
KEY `idx_pay_time` (`pay_time`),
KEY `idx_update_time` (`update_time`),
KEY `idx_buyer` (`buyer_phone`, `yz_open_id`),
KEY `idx_item_sku` (`item_id`, `sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='交易明细事实表 (T+1有赞同步)';
-- 客户维度宽表 (dim_customer_info)
-- 用于融合有赞买家、微信粉丝、线下POS留资构建 OneID 视图及基础标签
CREATE TABLE `dim_customer_info` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '中台内部统一客户ID (OneID)',
`mobile` VARCHAR(32) DEFAULT NULL COMMENT '手机号(首选关联枢纽,微信粉丝场景可能缺失)',
`yz_open_id` VARCHAR(64) DEFAULT NULL COMMENT '有赞生态统一用户ID备选主键',
`wx_union_id` VARCHAR(64) DEFAULT NULL COMMENT '微信生态UnionID',
`wx_open_id` VARCHAR(64) DEFAULT NULL COMMENT '微信公众号/小程序OpenID',
`nickname` VARCHAR(128) DEFAULT NULL COMMENT '客户昵称快照 (fans_nickname)',
`name` VARCHAR(64) DEFAULT NULL COMMENT '客户真实姓名(若有)',
`gender` TINYINT DEFAULT 0 COMMENT '性别: 0-未知, 1-男, 2-女',
`birthday` DATE DEFAULT NULL COMMENT '生日',
`register_time` DATETIME DEFAULT NULL COMMENT '首次注册/留资时间',
`register_channel` VARCHAR(32) DEFAULT NULL COMMENT '注册渠道 (有赞、线下门店、微信等)',
`member_level_id` BIGINT DEFAULT NULL COMMENT '当前会员等级ID',
`member_level_name` VARCHAR(64) DEFAULT NULL COMMENT '当前会员等级名称',
`first_pay_time` DATETIME DEFAULT NULL COMMENT '首单支付时间',
`last_pay_time` DATETIME DEFAULT NULL COMMENT '最近一次支付时间用于RFM-R计算',
`total_pay_amount` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '历史累计实付金额用于RFM-M计算',
`total_pay_count` INT DEFAULT 0 COMMENT '历史累计支付订单数用于RFM-F计算',
`customer_tags` JSON DEFAULT NULL COMMENT '客户身上的业务标签集合 (JSON格式存储如 ["高潜", "流失预警"])',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '中台数据更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yz_open_id` (`yz_open_id`),
UNIQUE KEY `uk_mobile` (`mobile`), -- 可空唯一: MySQL 允许多个 NULL, 有手机号则保证不重复
KEY `idx_wx_union_id` (`wx_union_id`),
KEY `idx_last_pay_time` (`last_pay_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一客户维度宽表 (OneID及基础RFM视角)';
-- ==========================================
-- 新增:商品维表、门店维表、库存流水、退货退款明细
-- ==========================================
-- 商品与SKU维表 (dim_item_sku)
-- 记录有赞商品的快照及外部映射,用以统一冷链和热食的销售分析
CREATE TABLE `dim_item_sku` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`item_id` BIGINT NOT NULL COMMENT '有赞商品ID (item_id)',
`sku_id` BIGINT NOT NULL COMMENT '有赞SKU ID (sku_id)',
`outer_item_id` VARCHAR(128) DEFAULT NULL COMMENT '商家外部商品编码 (关联核心)',
`outer_sku_id` VARCHAR(128) DEFAULT NULL COMMENT '商家外部SKU编码',
`barcode` VARCHAR(128) DEFAULT NULL COMMENT '商品条码',
`title` VARCHAR(255) NOT NULL COMMENT '商品/SKU名称',
`alias` VARCHAR(128) DEFAULT NULL COMMENT '商品别名/短链接标识',
`category_id` BIGINT DEFAULT NULL COMMENT '叶子类目ID',
`category_name` VARCHAR(128) DEFAULT NULL COMMENT '叶子类目名称',
`group_ids` VARCHAR(255) DEFAULT NULL COMMENT '商品分组ID列表 (逗号分隔)',
`price` BIGINT DEFAULT 0 COMMENT '当前售卖价(单位:分)',
`cost_price` BIGINT DEFAULT 0 COMMENT '成本价(单位:分,若有)',
`is_listing` TINYINT(1) DEFAULT 1 COMMENT '是否上架: 1-上架, 0-下架',
`created_time` DATETIME DEFAULT NULL COMMENT '商品在有赞的创建时间',
`update_time` DATETIME DEFAULT NULL COMMENT '商品最近更新时间',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '中台数据更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_item_sku` (`item_id`, `sku_id`),
KEY `idx_outer_item_id` (`outer_item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品与SKU维度表';
-- 门店与网点维表 (dim_offline_store)
CREATE TABLE `dim_offline_store` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`offline_id` BIGINT NOT NULL COMMENT '有赞网点/门店ID (offline_id / node_kdt_id)',
`shop_org_id` VARCHAR(64) DEFAULT NULL COMMENT '组织店铺ID',
`name` VARCHAR(128) NOT NULL COMMENT '门店名称',
`type` VARCHAR(32) DEFAULT NULL COMMENT '节点类型: STORE-门店, WAREHOUSE-仓库',
`province` VARCHAR(64) DEFAULT NULL COMMENT '省份',
`city` VARCHAR(64) DEFAULT NULL COMMENT '城市',
`district` VARCHAR(64) DEFAULT NULL COMMENT '区县',
`address` VARCHAR(255) DEFAULT NULL COMMENT '详细地址',
`status` TINYINT(1) DEFAULT 1 COMMENT '营业状态: 1-营业, 0-关店',
`warehouse_code` VARCHAR(64) DEFAULT NULL COMMENT '仓库编码 (库存API必需, warehouse.query获取)',
`stock_mode` INT DEFAULT NULL COMMENT '库存模式: 1-独立销售库存, 2-共享总部, 3-共享门店仓, 4-进出存单据管理',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '中台数据更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_offline_id` (`offline_id`),
KEY `idx_city` (`city`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门店与网点维度表';
-- 售后与退款明细事实表 (dwd_trade_refund_detail)
-- 用于计算交易净额 (GMV = 支付额 - 退款额)
CREATE TABLE `dwd_trade_refund_detail` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`refund_id` VARCHAR(64) NOT NULL COMMENT '退款单号 (refund_id)',
`tid` VARCHAR(64) NOT NULL COMMENT '关联的有赞主订单号 (tid)',
`oid` VARCHAR(64) DEFAULT NULL COMMENT '关联的子订单明细ID (oid, 若仅退款则为空)',
`offline_id` BIGINT DEFAULT NULL COMMENT '发起退款的门店ID (处理多网点)',
`refund_type` INT DEFAULT 1 COMMENT '退款类型: 1-仅退款, 2-退货退款',
`refund_fee` DECIMAL(12, 2) NOT NULL DEFAULT 0.00 COMMENT '实际退款金额',
`reason` VARCHAR(255) DEFAULT NULL COMMENT '退款原因 (整数编码: 54-未发货, 11-质量问题等)',
`status` VARCHAR(32) NOT NULL COMMENT '退款状态 (SUCCESS/CLOSED/WAIT_SELLER_AGREE等)',
`created_time` DATETIME DEFAULT NULL COMMENT '退款申请时间',
`success_time` DATETIME DEFAULT NULL COMMENT '退款成功时间',
`modified_time` DATETIME DEFAULT NULL COMMENT '退款最后修改时间',
`refund_demand` INT DEFAULT NULL COMMENT '退款诉求: 1-仅退款, 2-退货退款, 3-换货',
`refund_phase` INT DEFAULT NULL COMMENT '退款阶段: 1-售中, 2-售后',
`yz_open_id` VARCHAR(64) DEFAULT NULL COMMENT '买家有赞用户ID (yz_open_id)',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '中台数据更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_refund_id` (`refund_id`),
KEY `idx_tid` (`tid`),
KEY `idx_success_time` (`success_time`),
KEY `idx_yz_open_id` (`yz_open_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='售后与退款明细表';
-- 每日库存快照表 (dim_inventory_snapshot)
-- 记录每日各仓库/门店的SKU库存余量用于库存周转分析和缺货预警
CREATE TABLE `dim_inventory_snapshot` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`warehouse_code` VARCHAR(64) NOT NULL COMMENT '仓库编码 (warehouse.query获取)',
`sku_code` VARCHAR(128) NOT NULL COMMENT 'SKU编码 (outer_sku_id)',
`stock_num` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '实物库存数量',
`freeze_num` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '实物库存占用 (被订单锁定)',
`available_num` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '可用库存 (stock_num - freeze_num)',
`snapshot_date` DATE NOT NULL COMMENT '快照日期',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '中台同步时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_snapshot` (`warehouse_code`, `sku_code`, `snapshot_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日库存快照表';
-- 进销存库存流水事实表 (dwd_inventory_flow_di)
-- 记录每日出入库流水,辅助动销分析与损耗监控
CREATE TABLE `dwd_inventory_flow_di` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`biz_no` VARCHAR(64) NOT NULL COMMENT '业务单据号 (如盘点单号, 采购单号)',
`biz_type` VARCHAR(32) NOT NULL COMMENT '单据类型: PURCHASE-采购, ALLOCATE-调拨, CHECK-盘点, ADJUST-调整',
`offline_id` BIGINT NOT NULL COMMENT '发生动作的门店/仓库ID',
`item_id` BIGINT NOT NULL COMMENT '商品ID',
`sku_id` BIGINT NOT NULL COMMENT 'SKU ID',
`change_quantity` INT NOT NULL COMMENT '库存变动量 (正数为入, 负数为出)',
`after_quantity` INT DEFAULT NULL COMMENT '变动后该SKU的总可用库存',
`reason` VARCHAR(255) DEFAULT NULL COMMENT '变动原因 (如报损说明, 盘点原因)',
`occur_time` DATETIME NOT NULL COMMENT '流水发生时间',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '中台同步时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_flow` (`biz_no`, `item_id`, `sku_id`, `offline_id`, `biz_type`),
KEY `idx_offline_sku` (`offline_id`, `sku_id`),
KEY `idx_occur_time` (`occur_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存变动流水记录表';
-- ==========================================
-- 新增:优惠券/营销活动事实表
-- ==========================================
-- 优惠券流水事实表 (dwd_coupon_flow)
-- 记录优惠券的发放与核销用于活动ROI分析
CREATE TABLE `dwd_coupon_flow` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`coupon_group_id` BIGINT NOT NULL COMMENT '优惠券活动/券组ID (coupon_group_id)',
`coupon_id` VARCHAR(64) NOT NULL COMMENT '优惠券实例ID / 券码 (verify_code)',
`yz_open_id` VARCHAR(64) DEFAULT NULL COMMENT '领券/用券用户 (yz_open_id)',
`mobile` VARCHAR(32) DEFAULT NULL COMMENT '用户手机号',
`coupon_type` VARCHAR(32) DEFAULT NULL COMMENT '券类型: CASH-满减, DISCOUNT-折扣, EXCHANGE-兑换',
`coupon_name` VARCHAR(128) DEFAULT NULL COMMENT '券名称',
`denomination` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '面额/减免金额',
`condition_amount` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '使用门槛金额 (满X元可用)',
`send_time` DATETIME DEFAULT NULL COMMENT '发放时间',
`use_time` DATETIME DEFAULT NULL COMMENT '核销/使用时间',
`expire_time` DATETIME DEFAULT NULL COMMENT '过期时间',
`status` VARCHAR(32) NOT NULL COMMENT '券状态: SENT-已发, USED-已核销, EXPIRED-已过期',
`use_tid` VARCHAR(64) DEFAULT NULL COMMENT '核销时关联的订单号 (tid)',
`use_offline_id` BIGINT DEFAULT NULL COMMENT '核销门店ID (offline_id)',
`activity_id` BIGINT DEFAULT NULL COMMENT '关联营销活动ID (activity_id)',
`activity_type` VARCHAR(32) DEFAULT NULL COMMENT '活动类型',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '中台同步时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_coupon_id` (`coupon_id`),
KEY `idx_coupon_group` (`coupon_group_id`),
KEY `idx_yz_open_id` (`yz_open_id`),
KEY `idx_use_time` (`use_time`),
KEY `idx_use_tid` (`use_tid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠券流水事实表 (发放与核销)';
-- ==========================================
-- Phase 4: 商品与会员洞察 (ADM数据市集层)
-- ==========================================
-- 1. 客户 RFM 与评分模型表
CREATE TABLE IF NOT EXISTS `adm_customer_rfm` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`yz_open_id` VARCHAR(64) NOT NULL COMMENT '有赞open_id',
`mobile` VARCHAR(32) DEFAULT NULL COMMENT '客户手机号',
`last_trade_time` DATETIME DEFAULT NULL COMMENT '最近一次消费时间 (Recency)',
`trade_count_90d` INT DEFAULT 0 COMMENT '近90天消费频次 (Frequency)',
`trade_amount_90d` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '近90天消费金额 (Monetary)',
`r_score` INT DEFAULT 0 COMMENT 'R得分(1-5级)',
`f_score` INT DEFAULT 0 COMMENT 'F得分(1-5级)',
`m_score` INT DEFAULT 0 COMMENT 'M得分(1-5级)',
`rfm_group` VARCHAR(32) DEFAULT NULL COMMENT '客群分层(例如: 重要价值客户/沉睡客户等)',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '同步更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_yz_open_id` (`yz_open_id`),
KEY `idx_rfm_group` (`rfm_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会员RFM洞察表';
-- 2. 客户标签表 (人群特征画像)
CREATE TABLE IF NOT EXISTS `adm_customer_tags` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`yz_open_id` VARCHAR(64) NOT NULL,
`tag_name` VARCHAR(64) NOT NULL COMMENT '标签名称(如: 工作日外带客, 高客单囤货客)',
`tag_value` VARCHAR(128) DEFAULT NULL COMMENT '标签值或权重',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_customer_tag` (`yz_open_id`, `tag_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户画像标签表';
-- 3. 商品销售趋势表 (日维统计, 按 outer_item_id 聚合)
CREATE TABLE IF NOT EXISTS `adm_item_sales_trend` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`stat_date` DATE NOT NULL COMMENT '统计归属日期',
`item_id` BIGINT NOT NULL COMMENT '商品ID (代表性)',
`sku_id` BIGINT NOT NULL DEFAULT 0 COMMENT '规格ID',
`outer_item_id` VARCHAR(64) DEFAULT NULL COMMENT '商家统一商品编码',
`item_name` VARCHAR(128) DEFAULT NULL COMMENT '商品名称',
`sales_qty` INT DEFAULT 0 COMMENT '当日销量(件数)',
`sales_amount` DECIMAL(12, 2) DEFAULT 0.00 COMMENT '当日销售额',
`order_count` INT DEFAULT 0 COMMENT '当日包含该商品的订单数',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_trend_date` (`stat_date`, `outer_item_id`),
KEY `idx_item` (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品销售趋势表';
-- 4. 商品关联分析表 (购物篮挖掘, 按 outer_item_id 聚合)
CREATE TABLE IF NOT EXISTS `adm_item_basket` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`item_id_a` BIGINT NOT NULL COMMENT '商品A',
`outer_item_id_a` VARCHAR(64) DEFAULT NULL COMMENT '商品A商家编码',
`item_name_a` VARCHAR(128) DEFAULT NULL COMMENT '商品A名称',
`item_id_b` BIGINT NOT NULL COMMENT '商品B',
`outer_item_id_b` VARCHAR(64) DEFAULT NULL COMMENT '商品B商家编码',
`item_name_b` VARCHAR(128) DEFAULT NULL COMMENT '商品B名称',
`pair_order_count` INT DEFAULT 0 COMMENT 'A和B共同出现的订单数',
`item_a_order_count` INT DEFAULT 0 COMMENT 'A独立出现的包含订单总数',
`confidence` DECIMAL(5, 4) DEFAULT 0.0000 COMMENT '置信度 (A->B的概率)',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_item_pair` (`outer_item_id_a`, `outer_item_id_b`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品购物篮关联分析表';
-- 5. 商品复购分析表 (按 outer_item_id 聚合)
CREATE TABLE IF NOT EXISTS `adm_item_repurchase` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`stat_date` DATE NOT NULL COMMENT '统计归属日期',
`item_id` BIGINT NOT NULL COMMENT '商品ID (代表性)',
`sku_id` BIGINT NOT NULL DEFAULT 0,
`outer_item_id` VARCHAR(64) DEFAULT NULL COMMENT '商家统一商品编码',
`item_name` VARCHAR(128) DEFAULT NULL COMMENT '商品名称',
`purchaser_count_30d` INT DEFAULT 0 COMMENT '过去30天购买总人数',
`repurchaser_count_30d` INT DEFAULT 0 COMMENT '过去30天产生二次复购的人数',
`repurchase_rate_30d` DECIMAL(5, 4) DEFAULT 0.0000 COMMENT '30天复购率 (二次人数/总人数)',
`matrix_tag` VARCHAR(32) DEFAULT NULL COMMENT '波士顿矩阵打标分类 (如: 核心引流款, 需淘汰等)',
`etl_update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_repurchase_date` (`stat_date`, `outer_item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品复购率分析表';