Commit 6f6dffbd by yangxianglong

自采联营门店系统开发

parent 48906a68
# 汉尼外部API - 自采联营门店系统
# 汉尼外部 API(hanni-external-api)
养馋记与汉尼系统自采联营门店业务协同的后端 API 服务
**养馋记与汉尼系统自采联营门店** 的后端对接服务:从 **乐檬** 拉取商品档案并推送 **汉尼**,管理乐檬 **OAuth Token**(含持久化与定时刷新)
## 功能模块
---
1. **商品档案同步**:从乐檬获取商品档案,加工后传入汉尼;支持增量(`lastDownloadTime`)、变更检测与定时/手动任务
## 功能概览
## 技术栈
- Java 8、Spring Boot 2.7.18、Maven
- 乐檬:**RestTemplate**(商品查询、OAuth token),**无 apicloud-sdk**
| 能力 | 说明 |
|------|------|
| 商品同步 | 按乐檬 `nhsoft.amazon.basic.item.find` 多页拉取,映射为汉尼字段后调用新增/变更接口 |
| 增量 | 定时同步时根据上次 **SUCCESS** 日志的结束时间设置 `last_download_time` |
| 跳过未变化 | 依赖快照表比对乐檬「最后修改时间」字符串,减少重复推送 |
| 过滤策略 | **仅通过乐檬查询参数** `filter_sleep``filter_weed_out` 控制是否排除休眠/淘汰;请求体不在本地再按淘汰/休眠二次过滤 |
| Token | OAuth 授权码换 Token、手动设置、refresh;落库 `lemeng_oauth_token`,启动加载到内存 |
## 主要接口(context-path: `/api`)
---
| 模块 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 商品 | GET | `/item/sync` | 定时同款:按上次成功结束时间增量、**多页**拉取后同步汉尼 |
| 商品 | GET | `/item/sync/manual` | 手动:查询参数与乐檬 `item.find` 一致,**多页**拉取后同步(与 `/sync` 分页策略相同) |
| 商品 | GET | `/item/lemon` | 仅查乐檬**当前页** JSON,不同步 |
| 商品 | GET | `/item/sync-logs` | 同步日志分页(DB 分页,`status` 可选) |
| Token | POST | `/lemeng/token/set` | 写入数据库 `lemeng_oauth_token` 并同步内存 |
| Token | POST | `/lemeng/token/refresh` | `refresh_token` 刷新(HTTP 状态码区分 401/400) |
| 授权 | GET | `/auth/code` | OAuth2 回调换 token |
## 技术栈
## 乐檬配置
- Java 8、Spring Boot **2.7.18**、Maven
- Spring Web、Spring Data JPA、Actuator、SpringDoc OpenAPI 3
- 乐檬侧:**仅 RestTemplate**(商品 GET、OAuth),**不使用 apicloud-sdk**
- 开发默认 **H2 内存库**;生产建议使用 **MySQL**`spring.profiles.active=prod`
- `lemon.api.*`:商品 GET 地址与静态 `access-token`(可选,优先内存中的 OAuth Token)
- `lemeng.auth-server-url``lemeng.redirect-url``lemeng.app.*`:OAuth 与应用凭证
- **Token 持久化**:OAuth 回调、`/lemeng/token/set`、手动/定时刷新均会更新库表 `lemeng_oauth_token`**服务启动时自动加载到内存**。MySQL 可执行 `src/main/resources/db/mysql-lemeng-oauth-token.sql`;本地 H2 可通过 `ddl-auto=update` 自动建表。
---
## 快速开始
```bash
cd hanni-external-api
mvn clean compile
mvn spring-boot:run
```
- 根地址:`http://localhost:8081/api`
- Swagger:`/swagger-ui.html`
- 健康:`/actuator/health`
- **服务根路径**`http://localhost:8081/api``server.servlet.context-path=/api`
- **Swagger UI**`http://localhost:8081/api/swagger-ui.html`
- **OpenAPI JSON**`/api/v3/api-docs`
- **健康检查**`/api/actuator/health`
---
## HTTP 接口(相对 `/api`)
### 商品档案
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/item/sync` | 与定时任务同类:按上次成功结束时间增量,**多页**拉取后同步汉尼;请求乐檬时默认 `filter_sleep``filter_weed_out``true` |
| GET | `/item/sync/manual` | 手动同步;查询参数与乐檬 `item.find` 对齐,多页拉到底;`filterSleep`/`filterWeedOut` **未传时默认为 true** |
| GET | `/item/lemon` | 只查乐檬**当前页** JSON,**不同步**;过滤参数默认同上 |
| GET | `/item/sync-logs` | 同步日志分页,`status` 可选 |
| GET | `/item/sync-logs/{logId}/details` | 某次任务商品明细分页(ADD/UPDATE/SKIP 等) |
手动/调试时若需包含休眠或淘汰品,可显式传 `filterSleep=false``filterWeedOut=false`
### 乐檬 Token 与授权
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/auth/code` | OAuth2 回调:用 `code` 换 Token,写入库并刷新内存 |
| POST | `/lemeng/token/set` | 手工写入 `access_token` / `refresh_token`(按账套),**落库 + 内存** |
| POST | `/lemeng/token/refresh` | `grant_type=refresh_token`,成功后备份到库与内存 |
商品请求鉴权:**优先** 内存 `TokenHolder`(OAuth/上述接口写入),**否则** `application.yml``lemon.api.access-token`
---
## 配置说明(`application.yml`)
## 项目结构(摘要)
| 前缀 | 用途 |
|------|------|
| `server.port` / `server.servlet.context-path` | 默认 `8081`、上下文 `/api` |
| `spring.datasource.*` | 本地 H2;生产见 `application-prod.yml` |
| `spring.jpa.hibernate.ddl-auto` | 开发常用 `update`;生产请结合规范评估 |
| `lemon.api.*` | `base-url``item-find-url`、可选静态 `access-token``auth-type``bearer` / `header`) |
| `lemeng.*` | `auth-server-url``redirect-url``lemeng.app.app-id` / `app-secret` / `system-book-code` |
| `hanni.api.*` | 汉尼 `receive-goods-url``update-goods-url`(可选)、`sign-key`(MD5 签名) |
| `task.item-sync.*` | `enabled``cron`(默认定时同步,示例:每天 0 点) |
| `task.token-refresh.*` | 是否启用乐檬 Token 定时刷新(约每 50 分钟) |
环境变量示例(OAuth):`SYSTEM_BOOK_CODE``APP_ID``APP_SECRET`
**生产库**:激活 `prod` 后在 `application-prod.yml` 中配置数据源;**密码等敏感项建议改为环境变量注入,勿提交仓库。**
---
## 数据库
- **脚本位置**`src/main/resources/db/mysql-item-sync.sql`
- **主要表**`item_sync_log`(任务汇总)、`item_sync_snapshot`(条码快照/变更跳过)、`item_sync_detail`(单次明细)、`item_sync_cache`(旧版 MD5 缓存,可不用)、`lemeng_oauth_token`(Token 持久化)
- 本地 H2 可使用 `ddl-auto=update` 自动建表;MySQL 建议执行上述脚本或由 Hibernate 按策略创建(以运维规范为准)。
---
## 包结构(摘要)
```
cn.nhsoft.hanni/
├── client/ # LemonApiClient、HanniApiClient
├── common/util/ # Md5Util 等
├── item/ # 商品同步、LemonItemResponseParser、LemonItemFindRequestMapper
├── lemeng/ # Token、OAuth
└── task/ # ItemSyncTask
├── common/ # 统一响应、异常处理、工具类
├── config/ # RestTemplate、Swagger、各 Properties
├── item/ # 商品同步、转换器、仓储、控制器
├── lemeng/ # OAuth、Token、持久化、定时刷新 Job
└── task/ # ItemSyncTask 等定时入口
```
---
## 与前端联调
前端(`ycj-cloud-ui`)开发时将 **`/hanni-api`** 代理到本服务,并重写为 **`/api`**,例如:
- 前端:`GET /hanni-api/item/sync`
- 实际:`GET http://localhost:8081/api/item/sync`
详见工作区根目录 [`README.md`](../README.md)
......@@ -50,8 +50,8 @@ public class ItemController {
@Parameter(description = "最后修改时间(yyyy-MM-dd HH:mm:ss)") @RequestParam(required = false) String lastDownloadTime,
@Parameter(description = "商品类型") @RequestParam(required = false) Integer itemType,
@Parameter(description = "商品类别代码") @RequestParam(required = false) String itemCategoryCode,
@Parameter(description = "是否过滤休眠商品") @RequestParam(required = false) Boolean filterSleep,
@Parameter(description = "是否过滤淘汰商品") @RequestParam(required = false) Boolean filterWeedOut,
@Parameter(description = "是否过滤休眠商品,默认 true(仅同步非休眠)") @RequestParam(required = false) Boolean filterSleep,
@Parameter(description = "是否过滤淘汰商品,默认 true(仅同步非淘汰)") @RequestParam(required = false) Boolean filterWeedOut,
@Parameter(description = "商品编码,逗号分隔") @RequestParam(required = false) String itemNums,
@Parameter(description = "商品代码,逗号分隔") @RequestParam(required = false) String itemCodes) {
itemSyncService.syncItemsWithParams(LemonItemFindRequestMapper.fromQuery(
......@@ -68,8 +68,8 @@ public class ItemController {
@Parameter(description = "最后修改时间(yyyy-MM-dd HH:mm:ss)") @RequestParam(required = false) String lastDownloadTime,
@Parameter(description = "商品类型") @RequestParam(required = false) Integer itemType,
@Parameter(description = "商品类别代码") @RequestParam(required = false) String itemCategoryCode,
@Parameter(description = "是否过滤休眠商品") @RequestParam(required = false) Boolean filterSleep,
@Parameter(description = "是否过滤淘汰商品") @RequestParam(required = false) Boolean filterWeedOut,
@Parameter(description = "是否过滤休眠商品,默认 true(仅同步非休眠)") @RequestParam(required = false) Boolean filterSleep,
@Parameter(description = "是否过滤淘汰商品,默认 true(仅同步非淘汰)") @RequestParam(required = false) Boolean filterWeedOut,
@Parameter(description = "商品编码,逗号分隔") @RequestParam(required = false) String itemNums,
@Parameter(description = "商品代码,逗号分隔") @RequestParam(required = false) String itemCodes) {
String data = itemSyncService.getItemsFromLemon(LemonItemFindRequestMapper.fromQuery(
......
......@@ -18,7 +18,7 @@ import java.util.Map;
/**
* 乐檬商品数据转汉尼格式
* <p>乐檬字段 -> 汉尼字段映射</p>
* <p>乐檬字段 -> 汉尼字段映射。是否排除淘汰/休眠由乐檬接口查询参数 {@code filter_weed_out}、{@code filter_sleep} 控制,此处不再按返回体二次过滤。</p>
*/
@Slf4j
@Component
......@@ -89,9 +89,6 @@ public class LemonToHanniItemConverter {
private HanniItemDTO convertSingle(JsonNode node) {
try {
Map<String, Object> map = objectMapper.convertValue(node, new TypeReference<Map<String, Object>>() {});
if (!shouldIncludeItem(map)) {
return null;
}
return convertFromMap(map);
} catch (Exception e) {
log.warn("单条商品转换失败: {}", node, e);
......@@ -102,9 +99,6 @@ public class LemonToHanniItemConverter {
private LemonItemRow convertSingleToRow(JsonNode node) {
try {
Map<String, Object> map = objectMapper.convertValue(node, new TypeReference<Map<String, Object>>() {});
if (!shouldIncludeItem(map)) {
return null;
}
HanniItemDTO dto = convertFromMap(map);
String lemonMod = extractLemonLastModified(map);
return new LemonItemRow(dto, lemonMod);
......@@ -132,34 +126,6 @@ public class LemonToHanniItemConverter {
return null;
}
/**
* 养馋记商品过滤:是否停购=否,且不为淘汰、删除的商品才传入汉尼
*/
private boolean shouldIncludeItem(Map<String, Object> m) {
if (isTrue(m, "item_stop_purchase_flag", "item_purchase_stop_flag", "stop_purchase_flag")) {
return false;
}
if (isTrue(m, "item_weed_out_flag", "weed_out_flag", "weed_out")) {
return false;
}
if (isTrue(m, "item_delete_flag", "deleted", "delete_flag", "is_deleted")) {
return false;
}
return true;
}
private boolean isTrue(Map<String, Object> m, String... keys) {
for (String k : keys) {
Object v = m.get(k);
if (v != null) {
if (Boolean.TRUE.equals(v) || "true".equalsIgnoreCase(v.toString()) || "1".equals(v.toString())) {
return true;
}
}
}
return false;
}
private HanniItemDTO convertFromMap(Map<String, Object> m) {
HanniItemDTO dto = new HanniItemDTO();
dto.setBarcode(getStr(m, "item_barcode", "barcode"));
......
......@@ -118,6 +118,13 @@ public class ItemSyncServiceImpl implements ItemSyncService {
}
private void doSyncIncremental(LemonItemFindRequest request, String syncType) {
// 未显式设置时与定时任务一致:向乐檬传 filter_sleep/filter_weed_out(仅依赖接口查询参数)
if (request.getFilterSleep() == null) {
request.setFilterSleep(Boolean.TRUE);
}
if (request.getFilterWeedOut() == null) {
request.setFilterWeedOut(Boolean.TRUE);
}
runWithSyncLog(syncType, syncLog -> {
List<LemonItemRow> allRows = new ArrayList<>();
int pageNo = request.getPageNo() != null ? request.getPageNo() : 1;
......
......@@ -29,8 +29,9 @@ public final class LemonItemFindRequestMapper {
request.setLastDownloadTime(lastDownloadTime);
request.setItemType(itemType);
request.setItemCategoryCode(itemCategoryCode);
request.setFilterSleep(filterSleep);
request.setFilterWeedOut(filterWeedOut);
// 未传参时默认与乐檬 item.find 查询参数一致:filter_sleep/filter_weed_out=true(true=过滤掉休眠/淘汰)
request.setFilterSleep(filterSleep != null ? filterSleep : Boolean.TRUE);
request.setFilterWeedOut(filterWeedOut != null ? filterWeedOut : Boolean.TRUE);
if (itemNums != null && !itemNums.isEmpty()) {
request.setItemNums(Arrays.stream(itemNums.split(","))
.map(String::trim)
......
-- 汉尼外部 API 相关表(MySQL 8+, utf8mb4)
-- 含:商品同步、乐檬 OAuth Token 持久化
-- 执行前请替换库名: USE your_database;
SET NAMES utf8mb4;
-- ----------------------------
-- 同步任务汇总日志
-- ----------------------------
CREATE TABLE IF NOT EXISTS `item_sync_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`sync_type` VARCHAR(32) DEFAULT NULL COMMENT 'SCHEDULED/MANUAL/RUNNING 等',
`lemeng_count` INT DEFAULT NULL COMMENT '乐檬返回商品条数',
`hanni_count` INT DEFAULT NULL COMMENT '本批处理条数',
`status` VARCHAR(16) DEFAULT NULL COMMENT 'SUCCESS/FAIL/SKIP/RUNNING',
`hanni_response` TEXT DEFAULT NULL COMMENT '汉尼接口响应摘要',
`error_msg` TEXT DEFAULT NULL COMMENT '失败原因',
`start_time` DATETIME(3) DEFAULT NULL COMMENT '开始时间',
`end_time` DATETIME(3) DEFAULT NULL COMMENT '结束时间',
`duration_ms` BIGINT DEFAULT NULL COMMENT '耗时毫秒',
PRIMARY KEY (`id`),
KEY `idx_item_sync_log_start_time` (`start_time`),
KEY `idx_item_sync_log_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='乐檬→汉尼同步任务日志';
-- ----------------------------
-- 商品快照(汉尼字段 + 乐檬最后修改时间,用于跳过未变化)
-- ----------------------------
CREATE TABLE IF NOT EXISTS `item_sync_snapshot` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`barcode` VARCHAR(64) NOT NULL COMMENT '条码,唯一',
`gid` BIGINT DEFAULT NULL COMMENT '商品编码',
`name` VARCHAR(512) DEFAULT NULL,
`spec` VARCHAR(255) DEFAULT NULL,
`saleunit` VARCHAR(64) DEFAULT NULL,
`price` DECIMAL(18,4) DEFAULT NULL,
`retailprice` DECIMAL(18,4) DEFAULT NULL,
`parea` VARCHAR(255) DEFAULT NULL,
`isweight` VARCHAR(8) DEFAULT NULL,
`packratio` VARCHAR(64) DEFAULT NULL,
`grade` VARCHAR(64) DEFAULT NULL,
`model` VARCHAR(128) DEFAULT NULL,
`lemon_last_modified` VARCHAR(64) DEFAULT NULL COMMENT '乐檬最后修改时间(原始字符串)',
`updated_at` DATETIME(3) DEFAULT NULL COMMENT '本行更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_item_sync_snapshot_barcode` (`barcode`),
KEY `idx_snapshot_lemon_mod` (`lemon_last_modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品同步快照';
-- ----------------------------
-- 单次同步商品明细
-- ----------------------------
CREATE TABLE IF NOT EXISTS `item_sync_detail` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`sync_log_id` BIGINT NOT NULL COMMENT '关联 item_sync_log.id',
`barcode` VARCHAR(64) DEFAULT NULL,
`gid` BIGINT DEFAULT NULL,
`name` VARCHAR(512) DEFAULT NULL,
`action` VARCHAR(32) DEFAULT NULL COMMENT 'ADD/UPDATE/SKIP_TIME/SKIP_NO_BARCODE',
`lemon_last_modified` VARCHAR(64) DEFAULT NULL COMMENT '本次乐檬修改时间',
`stored_lemon_modified` VARCHAR(64) DEFAULT NULL COMMENT '快照中上次时间',
`success` TINYINT(1) DEFAULT NULL COMMENT '是否成功(批量接口时为同一摘要)',
`remark` VARCHAR(500) DEFAULT NULL,
`created_at` DATETIME(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_detail_sync_log_id` (`sync_log_id`),
KEY `idx_detail_log_barcode` (`sync_log_id`, `barcode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='同步任务商品明细';
-- ----------------------------
-- 旧版 MD5 变更缓存(当前逻辑已改用 snapshot,可不再使用;历史数据可保留)
-- ----------------------------
CREATE TABLE IF NOT EXISTS `item_sync_cache` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`barcode` VARCHAR(64) NOT NULL,
`content_hash` VARCHAR(64) DEFAULT NULL,
`update_time` DATETIME(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_item_sync_cache_barcode` (`barcode`),
KEY `idx_item_sync_cache_barcode` (`barcode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品同步缓存(旧)';
-- ----------------------------
-- 乐檬 OAuth Token 持久化(与 JPA 实体 LemengOAuthToken 对应;服务重启从库加载)
-- ----------------------------
CREATE TABLE IF NOT EXISTS `lemeng_oauth_token` (
`system_book_code` VARCHAR(64) NOT NULL COMMENT '账套号,主键',
`access_token` TEXT NOT NULL COMMENT '访问令牌',
`refresh_token` TEXT DEFAULT NULL COMMENT '刷新令牌',
`expires_at` DATETIME(3) DEFAULT NULL COMMENT 'access 预计过期时间',
`updated_at` DATETIME(3) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`system_book_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='乐檬 OAuth Token(重启从库加载)';
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment