Commit 25c521e9 by yangxianglong

自采联营门店系统开发

parents
# Maven
target/
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
dependency-reduced-pom.xml
buildNumber.properties
.mvn/timing.properties
.mvn/wrapper/maven-wrapper.jar
# IDE
.idea/
*.iml
*.ipr
*.iws
.project
.classpath
.settings/
.vscode/
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# Application
application-local.yml
application-local.properties
*.env
# 汉尼外部API - 自采联营门店系统
养馋记与汉尼系统自采联营门店业务协同的后端 API 服务。
## 功能模块
1. **商品档案同步**:从乐檬获取商品档案,加工后传入汉尼;支持增量(`lastDownloadTime`)、变更检测与定时/手动任务
## 技术栈
- Java 8、Spring Boot 2.7.18、Maven
- 乐檬:**RestTemplate**(商品查询、OAuth token),**无 apicloud-sdk**
## 主要接口(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` | 写入内存 Token |
| Token | POST | `/lemeng/token/refresh` | `refresh_token` 刷新(HTTP 状态码区分 401/400) |
| 授权 | GET | `/auth/code` | OAuth2 回调换 token |
## 乐檬配置
- `lemon.api.*`:商品 GET 地址与静态 `access-token`(可选,优先 `TokenHolder`
- `lemeng.auth-server-url``lemeng.redirect-url``lemeng.app.*`:OAuth 与应用凭证
## 快速开始
```bash
mvn clean compile
mvn spring-boot:run
```
- 根地址:`http://localhost:8081/api`
- Swagger:`/swagger-ui.html`
- 健康:`/actuator/health`
## 项目结构(摘要)
```
cn.nhsoft.hanni/
├── client/ # LemonApiClient、HanniApiClient
├── common/util/ # Md5Util 等
├── item/ # 商品同步、LemonItemResponseParser、LemonItemFindRequestMapper
├── lemeng/ # Token、OAuth
└── task/ # ItemSyncTask
```
<?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>2.7.18</version>
<relativePath/>
</parent>
<groupId>cn.nhsoft.hanni</groupId>
<artifactId>hanni-external-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>hanni-external-api</name>
<description>汉尼系统外部API - 自采联营门店系统(商品档案同步、批发订单对接)</description>
<properties>
<java.version>1.8</java.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Spring Boot Actuator (健康检查、监控) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Spring Boot Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 内存数据库 (开发/测试用) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<!-- MySQL 数据库 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Swagger/OpenAPI 3 (Spring Boot 2.7 兼容) -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.8.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
package cn.nhsoft.hanni;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 汉尼外部API应用启动类
* <p>
* 自采联营门店系统 - 支持商品档案同步、批发订单对接;乐檬接口使用 RestTemplate,不依赖 apicloud-sdk
* </p>
*/
@EnableScheduling
@SpringBootApplication
@EntityScan({"cn.nhsoft.hanni.item"})
@EnableJpaRepositories({"cn.nhsoft.hanni.item"})
public class HanniExternalApiApplication {
public static void main(String[] args) {
SpringApplication.run(HanniExternalApiApplication.class, args);
}
}
package cn.nhsoft.hanni.client;
import cn.nhsoft.hanni.common.util.Md5Util;
import cn.nhsoft.hanni.config.HanniApiProperties;
import cn.nhsoft.hanni.item.dto.HanniReceiveGoodsRequest;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.util.List;
/**
* 汉尼系统 API 调用客户端
* <p>商品档案同步接口: POST ycjhandler.ashx action=receiveGoods</p>
*
* @author hanni-external-api
*/
@Slf4j
@Component
public class HanniApiClient {
private static final String ACTION_RECEIVE_GOODS = "receiveGoods";
private static final String ACTION_UPDATE_GOODS = "updateGoods";
@Resource
private RestTemplate restTemplate;
@Resource
private HanniApiProperties hanniApiProperties;
@Resource
private ObjectMapper objectMapper;
/**
* 传入商品档案到汉尼系统(receiveGoods,新增)
*/
public String saveItems(List<?> items) {
return sendGoods(items, hanniApiProperties.getReceiveGoodsFullUrl(), ACTION_RECEIVE_GOODS);
}
/**
* 更新商品档案到汉尼系统(updateGoods,变更时调用)
*/
public String updateItems(List<?> items) {
return sendGoods(items, hanniApiProperties.getUpdateGoodsFullUrl(), ACTION_UPDATE_GOODS);
}
private String sendGoods(List<?> items, String url, String action) {
if (url == null || url.trim().isEmpty() || !url.startsWith("http")) {
log.warn("汉尼商品同步接口未配置,跳过");
return null;
}
log.info("调用汉尼商品接口: {} action={}", url, action);
try {
String dataJson = objectMapper.writeValueAsString(items);
String sign = generateSign(dataJson, action);
HanniReceiveGoodsRequest request = new HanniReceiveGoodsRequest();
request.setAction(action);
request.setData(dataJson);
request.setSign(sign);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<HanniReceiveGoodsRequest> entity = new HttpEntity<>(request, headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
return response.getBody();
} catch (JsonProcessingException e) {
log.error("汉尼商品数据序列化失败", e);
throw new RuntimeException("汉尼商品数据序列化失败: " + e.getMessage());
} catch (Exception e) {
log.error("调用汉尼商品同步接口失败", e);
throw new RuntimeException("汉尼商品同步失败: " + e.getMessage());
}
}
/**
* 生成签名
*/
private String generateSign(String data, String action) {
String key = hanniApiProperties.getSignKey();
if (key == null || key.isEmpty()) {
log.warn("汉尼签名密钥未配置,sign 为空");
return "";
}
String signStr = "action=" + action + "&data=" + data + "&key=" + key;
return Md5Util.md5Hex(signStr);
}
}
package cn.nhsoft.hanni.client;
import cn.nhsoft.hanni.config.LemonApiProperties;
import cn.nhsoft.hanni.item.dto.LemonItemFindRequest;
import cn.nhsoft.hanni.lemeng.util.TokenHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;
/**
* 乐檬商品查询 API 客户端(RestTemplate 方式,不使用 SDK)
*/
@Slf4j
@Component
public class LemonApiClient {
private static final String AUTH_HEADER_BEARER = "Bearer ";
private static final String AUTH_HEADER_X_ACCESS_TOKEN = "X-Access-Token";
@Resource
private RestTemplate restTemplate;
@Resource
private LemonApiProperties lemonApiProperties;
@Resource
private TokenHolder tokenHolder;
/**
* 查询乐檬商品档案
*/
public String findItems(LemonItemFindRequest request) {
String url = buildItemFindUrl(request);
log.info("调用乐檬商品档案查询接口(RestTemplate): {}", url);
try {
HttpHeaders headers = buildAuthHeaders();
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
return response.getBody();
} catch (Exception e) {
log.error("调用乐檬商品档案查询接口失败", e);
throw new RuntimeException("乐檬商品档案查询失败: " + e.getMessage());
}
}
/**
* 构建认证请求头:优先 TokenHolder(/lemeng/token/set 设置),其次 lemon.api.access-token 配置
*/
private HttpHeaders buildAuthHeaders() {
HttpHeaders headers = new HttpHeaders();
String token = tokenHolder.getAccessToken();
if (!StringUtils.hasText(token)) {
token = lemonApiProperties.getAccessToken();
}
if (StringUtils.hasText(token)) {
String authType = lemonApiProperties.getAuthType();
if ("header".equalsIgnoreCase(authType)) {
headers.set(AUTH_HEADER_X_ACCESS_TOKEN, token);
} else {
headers.set(HttpHeaders.AUTHORIZATION, AUTH_HEADER_BEARER + token);
}
}
return headers;
}
private String buildItemFindUrl(LemonItemFindRequest req) {
String baseUrl = lemonApiProperties.getItemFindFullUrl();
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(baseUrl)
.queryParam("page_no", req.getPageNo() != null ? req.getPageNo() : 1)
.queryParam("page_size", req.getPageSize() != null ? req.getPageSize() : 10);
if (req.getLastDownloadTime() != null && !req.getLastDownloadTime().isEmpty()) {
builder.queryParam("last_download_time", req.getLastDownloadTime());
}
if (req.getItemType() != null) {
builder.queryParam("item_type", req.getItemType());
}
if (req.getItemCategoryCode() != null && !req.getItemCategoryCode().isEmpty()) {
builder.queryParam("item_category_code", req.getItemCategoryCode());
}
if (req.getShowCommission() != null) {
builder.queryParam("show_commission", req.getShowCommission());
}
if (req.getItemNums() != null && !req.getItemNums().isEmpty()) {
builder.queryParam("item_nums", req.getItemNums().stream().map(String::valueOf).collect(Collectors.joining(",")));
}
if (req.getItemCodes() != null && !req.getItemCodes().isEmpty()) {
builder.queryParam("item_codes", String.join(",", req.getItemCodes()));
}
if (req.getQueryLabel() != null) {
builder.queryParam("query_label", req.getQueryLabel());
}
if (req.getFilterSleep() != null) {
builder.queryParam("filter_sleep", req.getFilterSleep());
}
if (req.getFilterWeedOut() != null) {
builder.queryParam("filter_weed_out", req.getFilterWeedOut());
}
if (req.getQueryGradeItem() != null) {
builder.queryParam("query_grade_item", req.getQueryGradeItem());
}
return builder.toUriString();
}
}
package cn.nhsoft.hanni.common;
import cn.nhsoft.hanni.common.constant.ResultCode;
import cn.nhsoft.hanni.common.constant.ResultMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 全局异常处理器
* <p>遵循阿里巴巴Java开发规范:异常必须处理,不得吞掉异常</p>
*
* @author hanni-external-api
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理 @Valid 参数校验异常(RequestBody)
*
* @param e 校验异常
* @return 统一响应
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleValidation(MethodArgumentNotValidException e) {
String message = Optional.ofNullable(e.getBindingResult().getFieldErrors())
.filter(errors -> !errors.isEmpty())
.map(errors -> errors.stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.joining("; ")))
.orElse(ResultMessage.VALIDATION_FAILED);
return Result.error(ResultCode.BAD_REQUEST, message);
}
/**
* 处理参数绑定异常(表单等)
*
* @param e 绑定异常
* @return 统一响应
*/
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Result<Void> handleBind(BindException e) {
String message = Optional.ofNullable(e.getBindingResult().getFieldErrors())
.filter(errors -> !errors.isEmpty())
.map(errors -> errors.stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.collect(Collectors.joining("; ")))
.orElse(ResultMessage.BIND_FAILED);
return Result.error(ResultCode.BAD_REQUEST, message);
}
/**
* 处理系统未捕获异常
*
* @param e 异常
* @return 统一响应
*/
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.error(ResultCode.SERVER_ERROR, e.getMessage());
}
}
package cn.nhsoft.hanni.common;
import cn.nhsoft.hanni.common.constant.ResultCode;
import cn.nhsoft.hanni.common.constant.ResultMessage;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一API响应封装
* <p>遵循阿里巴巴Java开发规范:使用常量替代魔法值</p>
*
* @author hanni-external-api
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
/**
* 响应码
*/
private int code;
/**
* 响应消息
*/
private String message;
/**
* 响应数据
*/
private T data;
/**
* 成功响应(带数据)
*
* @param data 响应数据
* @param <T> 数据类型
* @return Result
*/
public static <T> Result<T> success(T data) {
return new Result<>(ResultCode.SUCCESS, ResultMessage.SUCCESS, data);
}
/**
* 成功响应(无数据)
*
* @param <T> 数据类型
* @return Result
*/
public static <T> Result<T> success() {
return success(null);
}
/**
* 失败响应(指定码和消息)
*
* @param code 响应码
* @param message 响应消息
* @param <T> 数据类型
* @return Result
*/
public static <T> Result<T> error(int code, String message) {
return new Result<>(code, message, null);
}
/**
* 失败响应(默认500)
*
* @param message 响应消息
* @param <T> 数据类型
* @return Result
*/
public static <T> Result<T> error(String message) {
return error(ResultCode.SERVER_ERROR, message);
}
}
package cn.nhsoft.hanni.common.constant;
/**
* 统一响应码常量
* <p>遵循阿里巴巴Java开发规范:魔法值必须定义为常量</p>
*
* @author hanni-external-api
*/
public final class ResultCode {
private ResultCode() {
}
/**
* 成功
*/
public static final int SUCCESS = 200;
/**
* 客户端请求参数错误
*/
public static final int BAD_REQUEST = 400;
/**
* 服务端内部错误
*/
public static final int SERVER_ERROR = 500;
}
package cn.nhsoft.hanni.common.constant;
/**
* 统一响应消息常量
*
* @author hanni-external-api
*/
public final class ResultMessage {
private ResultMessage() {
}
/**
* 成功
*/
public static final String SUCCESS = "success";
/**
* 参数校验失败
*/
public static final String VALIDATION_FAILED = "参数校验失败";
/**
* 参数绑定失败
*/
public static final String BIND_FAILED = "参数绑定失败";
}
package cn.nhsoft.hanni.common.util;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* MD5 工具(十六进制小写)
*/
public final class Md5Util {
private Md5Util() {
}
public static String md5Hex(String input) {
String s = input == null ? "" : input;
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(s.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("MD5 不可用", e);
}
}
}
package cn.nhsoft.hanni.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 汉尼系统 API 配置属性
* <p>商品档案同步接口 ycjhandler.ashx action=receiveGoods</p>
*
* @author hanni-external-api
*/
@Data
@Component
@ConfigurationProperties(prefix = "hanni.api")
public class HanniApiProperties {
/**
* 汉尼商品同步 API(新增)
*/
private String receiveGoodsUrl = "http://ycj.hannikj.cn:8777/api/ycjhandler.ashx";
/**
* 汉尼商品更新 API(变更时调用,不配置则与 receiveGoodsUrl 相同)
*/
private String updateGoodsUrl;
/**
* 签名密钥
*/
private String signKey;
public String getReceiveGoodsFullUrl() {
return receiveGoodsUrl;
}
public String getUpdateGoodsFullUrl() {
return updateGoodsUrl != null && !updateGoodsUrl.isEmpty() ? updateGoodsUrl : receiveGoodsUrl;
}
}
package cn.nhsoft.hanni.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 乐檬商品查询 API 配置(RestTemplate 方式,不使用 SDK)
*/
@Data
@Component
@ConfigurationProperties(prefix = "lemon.api")
public class LemonApiProperties {
/**
* 乐檬 API 基础地址
*/
private String baseUrl = "https://cloud.nhsoft.cn";
/**
* 商品档案查询接口路径 (GET)
*/
private String itemFindUrl = "/api/nhsoft.amazon.basic.item.find/v2";
/**
* 访问令牌;或通过 POST /lemeng/token/set 写入 TokenHolder 后优先使用内存中的 token
*/
private String accessToken;
/**
* 认证方式:bearer(Authorization: Bearer token) 或 header(自定义头 X-Access-Token)
*/
private String authType = "bearer";
/**
* 获取商品档案查询完整 URL
*/
public String getItemFindFullUrl() {
return baseUrl + itemFindUrl;
}
}
package cn.nhsoft.hanni.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
/**
* RestTemplate 配置
* <p>用于第三方接口调用</p>
*
* @author hanni-external-api
*/
@Configuration
public class RestTemplateConfig {
/**
* 连接超时时间(毫秒)
*/
private static final int CONNECT_TIMEOUT = 10000;
/**
* 读取超时时间(毫秒)
*/
private static final int READ_TIMEOUT = 30000;
/**
* 创建 RestTemplate Bean
*
* @return RestTemplate
*/
@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT);
factory.setReadTimeout(READ_TIMEOUT);
return new RestTemplate(factory);
}
}
package cn.nhsoft.hanni.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger/OpenAPI 配置
*
* @author hanni-external-api
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI customOpenApi() {
return new OpenAPI()
.info(new Info()
.title("汉尼外部API - 自采联营门店系统")
.version("1.0.0")
.description("养馋记与汉尼系统自采联营门店业务协同接口,支持商品档案同步")
.contact(new Contact()
.name("hanni-external-api")));
}
}
package cn.nhsoft.hanni.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web 配置
* <p>配置跨域等Web相关参数</p>
*
* @author hanni-external-api
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
/**
* 配置跨域访问
*
* @param registry CORS注册器
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true);
}
}
package cn.nhsoft.hanni.item.controller;
import cn.nhsoft.hanni.common.Result;
import cn.nhsoft.hanni.item.dto.ItemSyncLogPageDTO;
import cn.nhsoft.hanni.item.model.ItemSyncLog;
import cn.nhsoft.hanni.item.repository.ItemSyncLogRepository;
import cn.nhsoft.hanni.item.service.ItemSyncService;
import cn.nhsoft.hanni.item.support.LemonItemFindRequestMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
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 javax.annotation.Resource;
/**
* 商品档案 - 控制器
*/
@Tag(name = "商品档案", description = "商品档案同步接口")
@RestController
@RequestMapping("/item")
public class ItemController {
@Resource
private ItemSyncService itemSyncService;
@Resource
private ItemSyncLogRepository itemSyncLogRepository;
@Operation(summary = "商品档案同步", description = "从乐檬获取商品档案,加工后传入汉尼系统;字段变更时调用汉尼更新接口")
@GetMapping("/sync")
public Result<Void> syncItems() {
itemSyncService.syncItems();
return Result.success();
}
@Operation(summary = "按乐檬参数手动同步", description = "与定时任务相同:按条件多页拉取直至不足一页,再同步到汉尼")
@GetMapping("/sync/manual")
public Result<Void> syncItemsManual(
@Parameter(description = "页码", required = true) @RequestParam(defaultValue = "1") Integer pageNo,
@Parameter(description = "分页大小(最大1000)", required = true) @RequestParam(defaultValue = "100") Integer pageSize,
@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 = "商品编码,逗号分隔") @RequestParam(required = false) String itemNums,
@Parameter(description = "商品代码,逗号分隔") @RequestParam(required = false) String itemCodes) {
itemSyncService.syncItemsWithParams(LemonItemFindRequestMapper.fromQuery(
pageNo, pageSize, lastDownloadTime, itemType, itemCategoryCode,
filterSleep, filterWeedOut, itemNums, itemCodes));
return Result.success();
}
@Operation(summary = "从乐檬获取商品档案", description = "调用乐檬 nhsoft.amazon.basic.item.find,仅查询当前页,不同步")
@GetMapping("/lemon")
public Result<String> getItemsFromLemon(
@Parameter(description = "页码", required = true) @RequestParam(defaultValue = "1") Integer pageNo,
@Parameter(description = "分页大小(最大1000)", required = true) @RequestParam(defaultValue = "10") Integer pageSize,
@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 = "商品编码,逗号分隔") @RequestParam(required = false) String itemNums,
@Parameter(description = "商品代码,逗号分隔") @RequestParam(required = false) String itemCodes) {
String data = itemSyncService.getItemsFromLemon(LemonItemFindRequestMapper.fromQuery(
pageNo, pageSize, lastDownloadTime, itemType, itemCategoryCode,
filterSleep, filterWeedOut, itemNums, itemCodes));
return Result.success(data);
}
@Operation(summary = "商品同步记录", description = "分页查询乐檬到汉尼商品同步日志,含 total 总条数")
@GetMapping("/sync-logs")
public Result<ItemSyncLogPageDTO> getSyncLogs(
@Parameter(description = "页码") @RequestParam(defaultValue = "0") Integer page,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "20") Integer size,
@Parameter(description = "状态筛选") @RequestParam(required = false) String status) {
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "startTime"));
Page<ItemSyncLog> resultPage;
if (status != null && !status.isEmpty()) {
resultPage = itemSyncLogRepository.findByStatusOrderByStartTimeDesc(status, pageable);
} else {
resultPage = itemSyncLogRepository.findAll(pageable);
}
ItemSyncLogPageDTO dto = new ItemSyncLogPageDTO(resultPage.getContent(), resultPage.getTotalElements());
return Result.success(dto);
}
}
package cn.nhsoft.hanni.item.converter;
import cn.nhsoft.hanni.item.dto.HanniItemDTO;
import cn.nhsoft.hanni.item.util.LemonItemResponseParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 乐檬商品数据转汉尼格式
* <p>乐檬字段 -> 汉尼字段映射</p>
*/
@Slf4j
@Component
public class LemonToHanniItemConverter {
@Resource
private ObjectMapper objectMapper;
/**
* 将乐檬接口返回的 JSON 转为汉尼商品列表
*
* @param lemonResponse 乐檬接口返回的 JSON 字符串
* @return 汉尼格式商品列表
*/
public List<HanniItemDTO> convert(String lemonResponse) {
if (lemonResponse == null || lemonResponse.trim().isEmpty()) {
return new ArrayList<>();
}
try {
JsonNode root = objectMapper.readTree(lemonResponse);
JsonNode itemsNode = LemonItemResponseParser.findItemsArray(root);
if (itemsNode == null || !itemsNode.isArray()) {
log.warn("乐檬返回中未找到商品列表数组,请检查接口返回结构");
return new ArrayList<>();
}
List<HanniItemDTO> result = new ArrayList<>();
for (JsonNode node : itemsNode) {
HanniItemDTO dto = convertSingle(node);
if (dto != null) {
result.add(dto);
}
}
return result;
} catch (Exception e) {
log.error("乐檬商品数据转换失败", e);
return new ArrayList<>();
}
}
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);
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"));
dto.setName(getStr(m, "item_name", "name"));
dto.setSpec(getStr(m, "item_spec", "spec"));
dto.setSaleunit(getStr(m, "item_unit", "saleunit", "unit"));
dto.setPrice(getDecimal(m, "item_cost_price", "price", "cost_price"));
dto.setRetailprice(getDecimal(m, "item_regular_price", "retailprice", "price"));
dto.setParea(getStr(m, "item_place", "parea", "place"));
dto.setIsweight(getBoolStr(m, "item_weight_flag", "isweight"));
dto.setPackratio(getStr(m, "item_purchase_rate", "packratio", "purchase_rate"));
dto.setGrade(getStr(m, "grade"));
dto.setModel(getStr(m, "model"));
dto.setGid(getLong(m, "item_num", "gid", "id"));
return dto;
}
private String getStr(Map<String, Object> m, String... keys) {
for (String k : keys) {
Object v = m.get(k);
if (v != null && !v.toString().isEmpty()) {
return v.toString();
}
}
return "";
}
private BigDecimal getDecimal(Map<String, Object> m, String... keys) {
for (String k : keys) {
Object v = m.get(k);
if (v != null) {
try {
return new BigDecimal(v.toString());
} catch (NumberFormatException ignored) {
}
}
}
return BigDecimal.ZERO;
}
private String getBoolStr(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 "1";
}
return "0";
}
}
return "0";
}
private Long getLong(Map<String, Object> m, String... keys) {
for (String k : keys) {
Object v = m.get(k);
if (v != null) {
try {
return Long.parseLong(v.toString());
} catch (NumberFormatException ignored) {
}
}
}
return null;
}
}
package cn.nhsoft.hanni.item.dto;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 汉尼商品档案 DTO
* <p>对应 receiveGoods 接口 data 中的商品对象</p>
*
* @author hanni-external-api
*/
@Data
public class HanniItemDTO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 商品条码
*/
private String barcode;
/**
* 商品名称
*/
private String name;
/**
* 规格
*/
private String spec;
/**
* 销售单位
*/
private String saleunit;
/**
* 进价/采购价
*/
private BigDecimal price;
/**
* 零售价
*/
private BigDecimal retailprice;
/**
* 产地
*/
private String parea;
/**
* 是否称重 0否 1是
*/
private String isweight;
/**
* 包装换算率
*/
private String packratio;
/**
* 等级
*/
private String grade;
/**
* 型号
*/
private String model;
/**
* 商品编码(gid)
*/
private Long gid;
}
package cn.nhsoft.hanni.item.dto;
import lombok.Data;
import java.io.Serializable;
/**
* 汉尼商品同步请求体
* <p>对应 ycjhandler.ashx action=receiveGoods</p>
*
* @author hanni-external-api
*/
@Data
public class HanniReceiveGoodsRequest implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 动作:receiveGoods
*/
private String action = "receiveGoods";
/**
* 商品数据,JSON 数组字符串
*/
private String data;
/**
* 签名
*/
private String sign;
}
package cn.nhsoft.hanni.item.dto;
import cn.nhsoft.hanni.item.model.ItemSyncLog;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 同步日志分页结果
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ItemSyncLogPageDTO {
private List<ItemSyncLog> records;
/** 总条数 */
private long total;
}
package cn.nhsoft.hanni.item.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 乐檬商品档案查询请求参数
* <p>对应接口 nhsoft.amazon.basic.item.find</p>
*
* @author hanni-external-api
*/
@Data
@Schema(description = "乐檬商品档案查询请求")
public class LemonItemFindRequest implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "查询页码", required = true, example = "1")
private Integer pageNo = 1;
@Schema(description = "分页大小(最大1000)", required = true, example = "10")
private Integer pageSize = 100;
@Schema(description = "最后修改时间(yyyy-MM-dd HH:mm:ss)", example = "2021-01-01 00:00:00")
private String lastDownloadTime;
@Schema(description = "商品类型")
private Integer itemType;
@Schema(description = "商品类别代码")
private String itemCategoryCode;
@Schema(description = "是否查询销售提成", example = "false")
private Boolean showCommission;
@Schema(description = "商品编码列表")
private List<Integer> itemNums;
@Schema(description = "商品代码列表")
private List<String> itemCodes;
@Schema(description = "是否查询商品标签", example = "true")
private Boolean queryLabel;
@Schema(description = "是否过滤休眠商品(true表示过滤)", example = "false")
private Boolean filterSleep;
@Schema(description = "是否过滤淘汰商品(true表示过滤)", example = "false")
private Boolean filterWeedOut;
@Schema(description = "是否查询分级,关键字搜索时使用", example = "true")
private Boolean queryGradeItem;
}
package cn.nhsoft.hanni.item.model;
import javax.persistence.*;
import java.util.Date;
/**
* 商品同步缓存,用于变更检测
*/
@Entity
@Table(name = "item_sync_cache", indexes = {@Index(columnList = "barcode")})
public class ItemSyncCache {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "barcode", length = 64, unique = true)
private String barcode;
@Column(name = "content_hash", length = 64)
private String contentHash;
@Column(name = "update_time")
@Temporal(TemporalType.TIMESTAMP)
private Date updateTime;
@PrePersist
@PreUpdate
public void prePersist() {
if (updateTime == null) {
updateTime = new Date();
}
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getBarcode() { return barcode; }
public void setBarcode(String barcode) { this.barcode = barcode; }
public String getContentHash() { return contentHash; }
public void setContentHash(String contentHash) { this.contentHash = contentHash; }
public Date getUpdateTime() { return updateTime; }
public void setUpdateTime(Date updateTime) { this.updateTime = updateTime; }
}
package cn.nhsoft.hanni.item.model;
import javax.persistence.*;
import java.util.Date;
/**
* 乐檬同步到汉尼商品日志记录
*
* @author hanni-external-api
*/
@Entity
@Table(name = "item_sync_log")
public class ItemSyncLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 同步类型:定时任务/手动/API
*/
@Column(name = "sync_type", length = 32)
private String syncType;
/**
* 乐檬商品数量
*/
@Column(name = "lemeng_count")
private Integer lemengCount;
/**
* 转换后汉尼商品数量
*/
@Column(name = "hanni_count")
private Integer hanniCount;
/**
* 同步状态:SUCCESS/FAIL
*/
@Column(name = "status", length = 16)
private String status;
/**
* 汉尼接口响应内容
*/
@Column(name = "hanni_response", columnDefinition = "TEXT")
private String hanniResponse;
/**
* 错误信息(失败时)
*/
@Column(name = "error_msg", columnDefinition = "TEXT")
private String errorMsg;
/**
* 同步开始时间
*/
@Column(name = "start_time")
@Temporal(TemporalType.TIMESTAMP)
private Date startTime;
/**
* 同步结束时间
*/
@Column(name = "end_time")
@Temporal(TemporalType.TIMESTAMP)
private Date endTime;
/**
* 耗时(毫秒)
*/
@Column(name = "duration_ms")
private Long durationMs;
@PrePersist
public void prePersist() {
if (startTime == null) {
startTime = new Date();
}
}
// getter/setter
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getSyncType() {
return syncType;
}
public void setSyncType(String syncType) {
this.syncType = syncType;
}
public Integer getLemengCount() {
return lemengCount;
}
public void setLemengCount(Integer lemengCount) {
this.lemengCount = lemengCount;
}
public Integer getHanniCount() {
return hanniCount;
}
public void setHanniCount(Integer hanniCount) {
this.hanniCount = hanniCount;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public String getHanniResponse() {
return hanniResponse;
}
public void setHanniResponse(String hanniResponse) {
this.hanniResponse = hanniResponse;
}
public String getErrorMsg() {
return errorMsg;
}
public void setErrorMsg(String errorMsg) {
this.errorMsg = errorMsg;
}
public Date getStartTime() {
return startTime;
}
public void setStartTime(Date startTime) {
this.startTime = startTime;
}
public Date getEndTime() {
return endTime;
}
public void setEndTime(Date endTime) {
this.endTime = endTime;
}
public Long getDurationMs() {
return durationMs;
}
public void setDurationMs(Long durationMs) {
this.durationMs = durationMs;
}
}
package cn.nhsoft.hanni.item.repository;
import cn.nhsoft.hanni.item.model.ItemSyncCache;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ItemSyncCacheRepository extends JpaRepository<ItemSyncCache, Long> {
Optional<ItemSyncCache> findByBarcode(String barcode);
List<ItemSyncCache> findByBarcodeIn(List<String> barcodes);
}
package cn.nhsoft.hanni.item.repository;
import cn.nhsoft.hanni.item.model.ItemSyncLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Date;
import java.util.List;
import java.util.Optional;
/**
* 乐檬到汉尼商品同步日志 Repository
*
* @author hanni-external-api
*/
public interface ItemSyncLogRepository extends JpaRepository<ItemSyncLog, Long> {
/**
* 按时间倒序查询最近 N 条
*/
List<ItemSyncLog> findTop20ByOrderByStartTimeDesc();
/**
* 按状态查询
*/
List<ItemSyncLog> findByStatusOrderByStartTimeDesc(String status);
/**
* 按状态分页查询(避免全表加载到内存)
*/
Page<ItemSyncLog> findByStatusOrderByStartTimeDesc(String status, Pageable pageable);
/**
* 按时间范围查询
*/
List<ItemSyncLog> findByStartTimeBetweenOrderByStartTimeDesc(Date start, Date end);
/**
* 查询最近一次成功同步记录(用于增量轮询的 lastDownloadTime)
*/
Optional<ItemSyncLog> findFirstByStatusOrderByEndTimeDesc(String status);
}
package cn.nhsoft.hanni.item.service;
import cn.nhsoft.hanni.item.dto.LemonItemFindRequest;
import java.util.List;
/**
* 商品档案同步服务接口
* <p>
* 负责从乐檬获取商品档案,加工后传入汉尼系统;
* 当养馋记系统字段变更时,调用汉尼系统更新接口
* </p>
*
* @author hanni-external-api
*/
public interface ItemSyncService {
/**
* 同步商品档案
* <p>从乐檬获取商品档案,加工后传入汉尼系统</p>
*/
void syncItems();
/**
* 从乐檬获取商品档案
* <p>仅调用乐檬接口,不做加工和同步;请求方式 GET,参数 page_no、page_size 必填</p>
*
* @param request 查询参数(对应 nhsoft.amazon.basic.item.find 接口文档)
* @return 乐檬商品档案原始响应
*/
String getItemsFromLemon(LemonItemFindRequest request);
/**
* 按乐檬查询参数手动同步到汉尼
* <p>使用指定查询参数从乐檬获取商品,经转换、过滤、变更检测后同步到汉尼</p>
*
* @param request 乐檬商品档案查询参数(与 nhsoft.amazon.basic.item.find 一致)
*/
void syncItemsWithParams(LemonItemFindRequest request);
}
package cn.nhsoft.hanni.item.service.impl;
import cn.nhsoft.hanni.client.HanniApiClient;
import cn.nhsoft.hanni.client.LemonApiClient;
import cn.nhsoft.hanni.common.util.Md5Util;
import cn.nhsoft.hanni.item.converter.LemonToHanniItemConverter;
import cn.nhsoft.hanni.item.dto.HanniItemDTO;
import cn.nhsoft.hanni.item.dto.LemonItemFindRequest;
import cn.nhsoft.hanni.item.model.ItemSyncCache;
import cn.nhsoft.hanni.item.model.ItemSyncLog;
import cn.nhsoft.hanni.item.repository.ItemSyncCacheRepository;
import cn.nhsoft.hanni.item.repository.ItemSyncLogRepository;
import cn.nhsoft.hanni.item.service.ItemSyncService;
import cn.nhsoft.hanni.item.util.LemonItemResponseParser;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
/**
* 商品档案同步服务实现类
*/
@Slf4j
@Service
public class ItemSyncServiceImpl implements ItemSyncService {
@Resource
private LemonApiClient lemonApiClient;
@Resource
private HanniApiClient hanniApiClient;
@Resource
private LemonToHanniItemConverter lemonToHanniItemConverter;
@Resource
private ItemSyncLogRepository itemSyncLogRepository;
@Resource
private ItemSyncCacheRepository itemSyncCacheRepository;
@Resource
private ObjectMapper objectMapper;
@FunctionalInterface
private interface SyncLogWork {
void execute(ItemSyncLog syncLog) throws Exception;
}
@Override
public void syncItems() {
LemonItemFindRequest findRequest = new LemonItemFindRequest();
findRequest.setPageNo(1);
findRequest.setPageSize(100);
findRequest.setFilterSleep(true);
findRequest.setFilterWeedOut(true);
Optional<ItemSyncLog> lastSuccess = itemSyncLogRepository.findFirstByStatusOrderByEndTimeDesc("SUCCESS");
if (lastSuccess.isPresent() && lastSuccess.get().getEndTime() != null) {
findRequest.setLastDownloadTime(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(lastSuccess.get().getEndTime()));
log.info("增量同步,lastDownloadTime={}", findRequest.getLastDownloadTime());
} else {
log.info("首次全量同步,无 lastDownloadTime");
}
doSyncIncremental(findRequest, "SCHEDULED");
}
@Override
public void syncItemsWithParams(LemonItemFindRequest request) {
if (request == null) {
request = new LemonItemFindRequest();
}
if (request.getPageNo() == null) {
request.setPageNo(1);
}
if (request.getPageSize() == null) {
request.setPageSize(100);
}
// 与定时任务一致:按条件多页拉取直至不足一页
doSyncIncremental(request, "MANUAL");
}
/**
* 增量/手动同步:分页拉取,复制完整查询条件
*/
private void doSyncIncremental(LemonItemFindRequest request, String syncType) {
runWithSyncLog(syncType, syncLog -> {
List<HanniItemDTO> allHanniItems = new ArrayList<>();
int pageNo = request.getPageNo() != null ? request.getPageNo() : 1;
int pageSize = request.getPageSize() != null ? request.getPageSize() : 100;
int lemengTotal = 0;
while (true) {
LemonItemFindRequest pageReq = copyPageRequest(request, pageNo, pageSize);
log.info("从乐檬获取商品档案,pageNo={}, lastDownloadTime={}", pageNo, request.getLastDownloadTime());
String lemonItems = lemonApiClient.findItems(pageReq);
List<HanniItemDTO> pageItems = lemonToHanniItemConverter.convert(lemonItems);
int count = parseLemengItemCount(lemonItems);
lemengTotal += count;
if (pageItems == null || pageItems.isEmpty()) {
break;
}
allHanniItems.addAll(pageItems);
if (count < pageSize) {
break;
}
pageNo++;
}
syncLog.setLemengCount(lemengTotal);
processAndSyncToHanniFromList(allHanniItems, syncLog);
});
}
private static LemonItemFindRequest copyPageRequest(LemonItemFindRequest src, int pageNo, int pageSize) {
LemonItemFindRequest p = new LemonItemFindRequest();
BeanUtils.copyProperties(src, p);
p.setPageNo(pageNo);
p.setPageSize(pageSize);
return p;
}
private void runWithSyncLog(String syncType, SyncLogWork work) {
ItemSyncLog syncLog = new ItemSyncLog();
syncLog.setSyncType(syncType);
long startMs = System.currentTimeMillis();
syncLog.setStartTime(new Date());
try {
work.execute(syncLog);
} catch (Exception e) {
log.error("商品同步失败", e);
syncLog.setStatus("FAIL");
syncLog.setErrorMsg(e.getMessage());
} finally {
syncLog.setEndTime(new Date());
syncLog.setDurationMs(System.currentTimeMillis() - startMs);
itemSyncLogRepository.save(syncLog);
}
}
private void processAndSyncToHanniFromList(List<HanniItemDTO> hanniItems, ItemSyncLog syncLog) {
syncLog.setHanniCount(hanniItems != null ? hanniItems.size() : 0);
if (hanniItems == null || hanniItems.isEmpty()) {
log.warn("转换后无有效商品数据,跳过同步");
syncLog.setStatus("SKIP");
syncLog.setErrorMsg("转换后无有效商品数据");
return;
}
log.info("转换得到 {} 条汉尼格式商品(已过滤停购/淘汰/删除)", hanniItems.size());
List<HanniItemDTO> toAdd = new ArrayList<>();
List<HanniItemDTO> toUpdate = new ArrayList<>();
for (HanniItemDTO item : hanniItems) {
String barcode = item.getBarcode();
if (barcode == null || barcode.isEmpty()) {
continue;
}
String hash = computeItemHash(item);
Optional<ItemSyncCache> cached = itemSyncCacheRepository.findByBarcode(barcode);
if (!cached.isPresent()) {
toAdd.add(item);
} else if (!hash.equals(cached.get().getContentHash())) {
toUpdate.add(item);
}
}
StringBuilder responseBuilder = new StringBuilder();
if (!toAdd.isEmpty()) {
log.info("新增 {} 条商品到汉尼", toAdd.size());
String r = hanniApiClient.saveItems(toAdd);
if (r != null) {
responseBuilder.append("add:").append(r);
}
}
if (!toUpdate.isEmpty()) {
log.info("更新 {} 条变更商品到汉尼", toUpdate.size());
String r = hanniApiClient.updateItems(toUpdate);
if (r != null) {
responseBuilder.append(" update:").append(r);
}
}
for (HanniItemDTO item : hanniItems) {
String barcode = item.getBarcode();
if (barcode == null || barcode.isEmpty()) {
continue;
}
String hash = computeItemHash(item);
ItemSyncCache cache = itemSyncCacheRepository.findByBarcode(barcode)
.orElse(new ItemSyncCache());
cache.setBarcode(barcode);
cache.setContentHash(hash);
itemSyncCacheRepository.save(cache);
}
syncLog.setStatus("SUCCESS");
syncLog.setHanniResponse(truncateResponse(responseBuilder.toString()));
}
private int parseLemengItemCount(String lemonJson) {
return LemonItemResponseParser.countItemsInResponse(lemonJson, objectMapper);
}
private String truncateResponse(String response) {
if (response == null) {
return null;
}
return response.length() > 2000 ? response.substring(0, 2000) + "..." : response;
}
private String computeItemHash(HanniItemDTO item) {
String str = String.format("%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s",
nullToEmpty(item.getBarcode()), nullToEmpty(item.getName()), nullToEmpty(item.getSpec()),
nullToEmpty(item.getSaleunit()), nullToEmpty(item.getPrice()), nullToEmpty(item.getRetailprice()),
nullToEmpty(item.getParea()), nullToEmpty(item.getIsweight()), nullToEmpty(item.getPackratio()),
nullToEmpty(item.getGrade()), nullToEmpty(item.getModel()));
return Md5Util.md5Hex(str);
}
private String nullToEmpty(Object o) {
return o == null ? "" : o.toString();
}
@Override
public String getItemsFromLemon(LemonItemFindRequest request) {
if (request == null) {
request = new LemonItemFindRequest();
}
if (request.getPageNo() == null) {
request.setPageNo(1);
}
if (request.getPageSize() == null) {
request.setPageSize(100);
}
return lemonApiClient.findItems(request);
}
}
package cn.nhsoft.hanni.item.support;
import cn.nhsoft.hanni.item.dto.LemonItemFindRequest;
import java.util.Arrays;
import java.util.stream.Collectors;
/**
* 将 HTTP 查询参数组装为 {@link LemonItemFindRequest}
*/
public final class LemonItemFindRequestMapper {
private LemonItemFindRequestMapper() {
}
public static LemonItemFindRequest fromQuery(
Integer pageNo,
Integer pageSize,
String lastDownloadTime,
Integer itemType,
String itemCategoryCode,
Boolean filterSleep,
Boolean filterWeedOut,
String itemNums,
String itemCodes) {
LemonItemFindRequest request = new LemonItemFindRequest();
request.setPageNo(pageNo);
request.setPageSize(pageSize);
request.setLastDownloadTime(lastDownloadTime);
request.setItemType(itemType);
request.setItemCategoryCode(itemCategoryCode);
request.setFilterSleep(filterSleep);
request.setFilterWeedOut(filterWeedOut);
if (itemNums != null && !itemNums.isEmpty()) {
request.setItemNums(Arrays.stream(itemNums.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.filter(s -> s.matches("\\d+"))
.map(Integer::parseInt)
.collect(Collectors.toList()));
}
if (itemCodes != null && !itemCodes.isEmpty()) {
request.setItemCodes(Arrays.stream(itemCodes.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList()));
}
return request;
}
}
package cn.nhsoft.hanni.item.util;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* 乐檬商品查询响应 JSON 解析:统一「商品数组」定位逻辑,供转换与计数共用
*/
public final class LemonItemResponseParser {
private static final String[] NESTED_ARRAY_PATHS = {
"data/list/items",
"data/list/records",
"data/records",
"data/items",
"list/items",
"list/records"
};
private static final String[] SHALLOW_ARRAY_KEYS = {"items", "records", "list", "data", "result"};
private LemonItemResponseParser() {
}
/**
* 在乐檬接口响应 JSON 中定位商品数组节点(与计数逻辑一致)
*/
public static JsonNode findItemsArray(JsonNode root) {
if (root == null || root.isNull()) {
return null;
}
if (root.isArray()) {
return root;
}
for (String path : NESTED_ARRAY_PATHS) {
JsonNode node = navigate(root, path);
if (node != null && node.isArray()) {
return node;
}
}
for (String key : SHALLOW_ARRAY_KEYS) {
JsonNode node = root.get(key);
if (node != null && node.isArray()) {
return node;
}
}
return null;
}
/**
* 统计响应中商品条数(与 {@link #findItemsArray} 同源)
*/
public static int countItemsInResponse(String json, ObjectMapper objectMapper) {
if (json == null || json.isEmpty()) {
return 0;
}
try {
JsonNode root = objectMapper.readTree(json);
JsonNode arr = findItemsArray(root);
return arr != null && arr.isArray() ? arr.size() : 0;
} catch (Exception e) {
return 0;
}
}
private static JsonNode navigate(JsonNode root, String slashPath) {
JsonNode node = root;
for (String p : slashPath.split("/")) {
if (node == null || node.isMissingNode()) {
return null;
}
node = node.get(p);
}
return node;
}
}
package cn.nhsoft.hanni.lemeng.client;
import cn.nhsoft.hanni.lemeng.config.LemengProperties;
import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import javax.annotation.Resource;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* 乐檬 OAuth2 Token(RestTemplate,无 SDK)
* POST {authServer}/oauth/token
*/
@Slf4j
@Component
public class LemengTokenRestClient {
@Resource
private RestTemplate restTemplate;
@Resource
private ObjectMapper objectMapper;
@Resource
private LemengProperties lemengProperties;
/**
* grant_type=refresh_token
*/
public TokenRefreshResult refresh(String appId, String appSecret, String refreshToken) {
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "refresh_token");
body.add("refresh_token", refreshToken);
return postToken(appId, appSecret, body);
}
/**
* grant_type=authorization_code(OAuth 回调)
*/
public TokenRefreshResult exchangeAuthorizationCode(String appId, String appSecret, String code, String redirectUri) {
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("code", code);
body.add("redirect_uri", redirectUri);
return postToken(appId, appSecret, body);
}
private TokenRefreshResult postToken(String appId, String appSecret, MultiValueMap<String, String> body) {
String credential = "Basic " + Base64.getEncoder().encodeToString((appId + ":" + appSecret).getBytes(StandardCharsets.UTF_8));
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.set("Authorization", credential);
String url = lemengProperties.tokenEndpointUrl();
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(body, headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, entity, String.class);
String json = response.getBody();
if (json == null || json.isEmpty()) {
throw new RuntimeException("乐檬 token 接口返回为空");
}
try {
JsonNode root = objectMapper.readTree(json);
String accessToken = getText(root, "access_token");
String newRefreshToken = getText(root, "refresh_token");
Integer expiresIn = root.has("expires_in") && !root.get("expires_in").isNull()
? root.get("expires_in").asInt() : null;
String systemBookCode = getText(root, "Nhsoft-Merchant-Id");
if (accessToken == null || accessToken.isEmpty()) {
throw new RuntimeException("返回中无 access_token: " + json);
}
if (systemBookCode == null || systemBookCode.isEmpty()) {
systemBookCode = parseMerchantIdFromJwt(accessToken);
}
return new TokenRefreshResult(systemBookCode, accessToken, newRefreshToken, expiresIn);
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
log.error("解析 token 响应失败: {}", json, e);
throw new RuntimeException("解析 token 响应失败: " + e.getMessage());
}
}
/**
* 从 access_token(JWT)解析乐檬账套号 Nhsoft-Merchant-Id
*/
public String parseMerchantIdFromJwt(String accessToken) {
if (accessToken == null || !accessToken.contains(".")) {
return null;
}
try {
String[] parts = accessToken.split("\\.");
if (parts.length < 2) {
return null;
}
byte[] decoded = decodeJwtPayloadBase64Url(parts[1]);
JsonNode node = objectMapper.readTree(decoded);
JsonNode mid = node.get("Nhsoft-Merchant-Id");
return mid != null && !mid.isNull() ? mid.asText() : null;
} catch (Exception e) {
log.warn("解析 JWT 账套号失败: {}", e.getMessage());
return null;
}
}
/**
* 解析账套号:响应字段 → JWT → 配置兜底
*/
public String resolveSystemBookCode(TokenRefreshResult result) {
if (result == null) {
return null;
}
if (result.getSystemBookCode() != null && !result.getSystemBookCode().isEmpty()) {
return result.getSystemBookCode();
}
String fromJwt = parseMerchantIdFromJwt(result.getAccessToken());
if (fromJwt != null && !fromJwt.isEmpty()) {
return fromJwt;
}
LemengProperties.App app = lemengProperties.getApp();
return app != null ? app.getSystemBookCode() : null;
}
private String getText(JsonNode node, String key) {
JsonNode n = node.get(key);
return n != null && !n.isNull() ? n.asText() : null;
}
private static byte[] decodeJwtPayloadBase64Url(String payload) {
String p = payload;
int mod = p.length() % 4;
if (mod > 0) {
p += "====".substring(mod);
}
return Base64.getUrlDecoder().decode(p);
}
}
package cn.nhsoft.hanni.lemeng.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 乐檬 OAuth / 应用配置(不依赖 SDK,与 RestTemplate 调用一致)
*/
@Data
@Component
@ConfigurationProperties(prefix = "lemeng")
public class LemengProperties {
/**
* 认证服务根地址,如 https://cloud.nhsoft.cn/authserver
*/
private String authServerUrl = "https://cloud.nhsoft.cn/authserver";
/**
* OAuth2 授权回调地址(需与乐檬控制台配置一致)
*/
private String redirectUrl = "http://127.0.0.1:8081/api/auth/code";
private App app = new App();
@Data
public static class App {
/** 默认账套号(JWT 无法解析时的兜底) */
private String systemBookCode;
private String appId;
private String appSecret;
}
public String tokenEndpointUrl() {
String base = authServerUrl == null ? "" : authServerUrl.trim();
if (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
return base + "/oauth/token";
}
}
package cn.nhsoft.hanni.lemeng.controller;
import cn.nhsoft.hanni.lemeng.client.LemengTokenRestClient;
import cn.nhsoft.hanni.lemeng.config.LemengProperties;
import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult;
import cn.nhsoft.hanni.lemeng.util.TokenHolder;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.annotation.Resource;
/**
* 乐檬 OAuth2 授权回调(RestTemplate 换 token,无 SDK)
*/
@Tag(name = "乐檬授权", description = "乐檬 OAuth2 授权回调")
@Slf4j
@Controller
@RequestMapping("/auth")
public class LemengAuthController {
@Resource
private LemengTokenRestClient lemengTokenRestClient;
@Resource
private LemengProperties lemengProperties;
@Resource
private TokenHolder tokenHolder;
@Operation(summary = "OAuth2 授权回调", description = "使用 code 换取 access_token 并写入 TokenHolder;无 code 时跳转 Swagger")
@GetMapping("/code")
public String getCode(
@Parameter(description = "OAuth2 授权码") @RequestParam(value = "code", required = false) String code,
@Parameter(description = "OAuth2 state") @RequestParam(value = "state", required = false) String state) {
if (!StringUtils.hasText(code)) {
return "redirect:/swagger-ui.html";
}
LemengProperties.App app = lemengProperties.getApp();
String appId = app != null ? app.getAppId() : null;
String appSecret = app != null ? app.getAppSecret() : null;
if (!StringUtils.hasText(appId) || !StringUtils.hasText(appSecret)) {
log.error("lemeng.app.app-id 或 app-secret 未配置,无法换取 token");
return "redirect:/swagger-ui.html?lemeng_auth=config";
}
String redirectUri = lemengProperties.getRedirectUrl();
if (!StringUtils.hasText(redirectUri)) {
log.error("lemeng.redirect-url 未配置");
return "redirect:/swagger-ui.html?lemeng_auth=config";
}
try {
TokenRefreshResult result = lemengTokenRestClient.exchangeAuthorizationCode(appId, appSecret, code, redirectUri);
String book = lemengTokenRestClient.resolveSystemBookCode(result);
if (!StringUtils.hasText(book)) {
log.error("无法解析账套号,请配置 lemeng.app.system-book-code");
return "redirect:/swagger-ui.html?lemeng_auth=merchant";
}
tokenHolder.setToken(book, result.getAccessToken(), result.getRefreshToken());
log.info("OAuth 授权成功,账套: {}", book);
} catch (Exception e) {
log.error("乐檬 OAuth 获取 Token 失败", e);
return "redirect:/swagger-ui.html?lemeng_auth=fail";
}
return "redirect:/swagger-ui.html";
}
}
package cn.nhsoft.hanni.lemeng.controller;
import cn.nhsoft.hanni.common.Result;
import cn.nhsoft.hanni.lemeng.client.LemengTokenRestClient;
import cn.nhsoft.hanni.lemeng.config.LemengProperties;
import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult;
import cn.nhsoft.hanni.lemeng.util.TokenHolder;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.RestClientException;
import javax.annotation.Resource;
/**
* 乐檬 Token 管理(RestTemplate,无 SDK)
*/
@Slf4j
@Tag(name = "乐檬Token", description = "乐檬 OAuth Token 刷新与设置")
@RestController
@RequestMapping("/lemeng/token")
public class LemengTokenController {
@Resource
private LemengTokenRestClient lemengTokenRestClient;
@Resource
private TokenHolder tokenHolder;
@Resource
private LemengProperties lemengProperties;
@Operation(summary = "刷新 Token", description = "POST /oauth/token,grant_type=refresh_token")
@PostMapping("/refresh")
public Result<TokenRefreshResult> refreshToken(
@Parameter(description = "refresh_token", required = true) @RequestParam String refreshToken) {
LemengProperties.App app = lemengProperties.getApp();
String appId = app != null ? app.getAppId() : null;
String appSecret = app != null ? app.getAppSecret() : null;
if (!StringUtils.hasText(appId) || !StringUtils.hasText(appSecret)) {
return Result.error("lemeng.app.app-id 或 lemeng.app.app-secret 未配置");
}
if (!StringUtils.hasText(refreshToken)) {
return Result.error("refreshToken 不能为空");
}
try {
TokenRefreshResult result = lemengTokenRestClient.refresh(appId, appSecret, refreshToken);
String book = lemengTokenRestClient.resolveSystemBookCode(result);
if (!StringUtils.hasText(book)) {
return Result.error("无法解析账套号,请配置 lemeng.app.system-book-code 或检查 token");
}
tokenHolder.setToken(book, result.getAccessToken(), result.getRefreshToken());
return Result.success(result);
} catch (HttpClientErrorException e) {
HttpStatus status = e.getStatusCode();
log.warn("刷新 Token HTTP {} {}", status.value(), e.getResponseBodyAsString());
if (status == HttpStatus.UNAUTHORIZED) {
return Result.error("401 认证失败:请确认 appid、appsecret 与 refresh_token 所属应用一致");
}
if (status == HttpStatus.BAD_REQUEST) {
return Result.error("refresh_token 无效或已过期,请重新完成 OAuth 授权获取新 token");
}
String body = e.getResponseBodyAsString();
String hint = body != null && body.length() > 300 ? body.substring(0, 300) + "..." : body;
return Result.error("刷新失败: HTTP " + status.value() + " " + hint);
} catch (HttpServerErrorException e) {
log.error("刷新 Token 乐檬服务端错误", e);
return Result.error("刷新失败: 乐檬服务 HTTP " + e.getStatusCode().value());
} catch (RestClientException e) {
log.error("刷新 Token 网络/客户端异常", e);
return Result.error("刷新失败: " + e.getMessage());
} catch (Exception e) {
log.error("刷新 Token 失败", e);
return Result.error("刷新失败: " + e.getMessage());
}
}
@Operation(summary = "手动设置 Token", description = "将 access_token、refresh_token 写入内存 TokenHolder,供乐檬商品查询使用")
@PostMapping("/set")
public Result<String> setToken(
@Parameter(description = "账套号", required = true) @RequestParam String systemBookCode,
@Parameter(description = "access_token", required = true) @RequestParam String accessToken,
@Parameter(description = "refresh_token", required = true) @RequestParam String refreshToken) {
if (!StringUtils.hasText(systemBookCode) || !StringUtils.hasText(accessToken) || !StringUtils.hasText(refreshToken)) {
return Result.error("systemBookCode、accessToken、refreshToken 均不能为空");
}
tokenHolder.setToken(systemBookCode, accessToken, refreshToken);
log.info("手动设置 Token 成功,账套号: {}", systemBookCode);
return Result.success(systemBookCode);
}
}
package cn.nhsoft.hanni.lemeng.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Token 刷新结果
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TokenRefreshResult {
/**
* 账套号(从 JWT 解析)
*/
private String systemBookCode;
/**
* 新的 access_token
*/
private String accessToken;
/**
* 新的 refresh_token
*/
private String refreshToken;
/**
* 过期时间(秒),如有
*/
private Integer expiresIn;
}
package cn.nhsoft.hanni.lemeng.job;
import cn.nhsoft.hanni.lemeng.client.LemengTokenRestClient;
import cn.nhsoft.hanni.lemeng.config.LemengProperties;
import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult;
import cn.nhsoft.hanni.lemeng.util.TokenHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.Map;
/**
* 乐檬 Token 定时刷新(expires_in 约 3600 秒,每 50 分钟刷新)
*/
@Slf4j
@Component
@ConditionalOnProperty(prefix = "task.token-refresh", name = "enabled", havingValue = "true", matchIfMissing = true)
public class LemengTokenRefreshJob {
@Resource
private LemengTokenRestClient lemengTokenRestClient;
@Resource
private TokenHolder tokenHolder;
@Resource
private LemengProperties lemengProperties;
@Scheduled(fixedDelay = 50 * 60 * 1000, initialDelay = 60 * 1000)
public void refreshToken() {
LemengProperties.App app = lemengProperties.getApp();
String appId = app != null ? app.getAppId() : null;
String appSecret = app != null ? app.getAppSecret() : null;
if (!StringUtils.hasText(appId) || !StringUtils.hasText(appSecret)) {
log.debug("lemeng.app.app-id/app-secret 未配置,跳过 token 定时刷新");
return;
}
Map<String, TokenHolder.TokenPair> all = tokenHolder.getAllTokens();
if (all.isEmpty()) {
log.debug("无已设置的 token,跳过定时刷新");
return;
}
for (Map.Entry<String, TokenHolder.TokenPair> e : all.entrySet()) {
String systemBookCode = e.getKey();
String refreshToken = e.getValue().getRefreshToken();
if (!StringUtils.hasText(refreshToken)) {
log.warn("账套 {} 无 refresh_token,跳过", systemBookCode);
continue;
}
try {
TokenRefreshResult result = lemengTokenRestClient.refresh(appId, appSecret, refreshToken);
String book = lemengTokenRestClient.resolveSystemBookCode(result);
if (!StringUtils.hasText(book)) {
book = systemBookCode;
}
tokenHolder.setToken(book, result.getAccessToken(), result.getRefreshToken());
log.info("定时刷新 token 成功,账套: {}", book);
} catch (Exception ex) {
log.error("定时刷新账套 {} token 失败", systemBookCode, ex);
}
}
}
}
package cn.nhsoft.hanni.lemeng.util;
import lombok.Data;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 内存 Token 存储,供 RestTemplate 方式商品查询及定时刷新使用
* 通过 /lemeng/token/set 设置后,LemonApiClient 从此读取
*/
@Component
public class TokenHolder {
@Data
public static class TokenPair {
private String accessToken;
private String refreshToken;
}
private static final Map<String, TokenPair> TOKENS = new ConcurrentHashMap<>();
private static volatile String defaultSystemBookCode;
public void setToken(String systemBookCode, String accessToken, String refreshToken) {
if (StringUtils.hasText(systemBookCode) && StringUtils.hasText(accessToken)) {
TokenPair pair = new TokenPair();
pair.setAccessToken(accessToken);
pair.setRefreshToken(StringUtils.hasText(refreshToken) ? refreshToken : null);
TOKENS.put(systemBookCode, pair);
defaultSystemBookCode = systemBookCode;
}
}
public String getAccessToken(String systemBookCode) {
TokenPair pair = getTokenPair(systemBookCode);
return pair != null ? pair.getAccessToken() : null;
}
public String getAccessToken() {
return getAccessToken(defaultSystemBookCode);
}
public String getRefreshToken(String systemBookCode) {
TokenPair pair = getTokenPair(systemBookCode);
return pair != null ? pair.getRefreshToken() : null;
}
public String getRefreshToken() {
return getRefreshToken(defaultSystemBookCode);
}
private TokenPair getTokenPair(String systemBookCode) {
if (StringUtils.hasText(systemBookCode)) {
return TOKENS.get(systemBookCode);
}
return defaultSystemBookCode != null ? TOKENS.get(defaultSystemBookCode) : null;
}
public String getDefaultSystemBookCode() {
return defaultSystemBookCode;
}
public Map<String, TokenPair> getAllTokens() {
return new ConcurrentHashMap<>(TOKENS);
}
}
package cn.nhsoft.hanni.task;
import cn.nhsoft.hanni.item.service.ItemSyncService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 商品档案同步定时任务
* <p>每天凌晨执行商品档案同步,可通过 task.item-sync.enabled 和 task.item-sync.cron 配置</p>
*
* @author hanni-external-api
*/
@Slf4j
@Component
@ConditionalOnProperty(prefix = "task.item-sync", name = "enabled", havingValue = "true", matchIfMissing = true)
public class ItemSyncTask {
@Resource
private ItemSyncService itemSyncService;
/**
* 每天凌晨 00:00:00 执行商品档案同步
* <p>cron: 秒 分 时 日 月 周,可通过 task.item-sync.cron 覆盖</p>
*/
@Scheduled(cron = "${task.item-sync.cron:0 0 0 * * ?}")
public void syncItems() {
log.info("定时任务开始执行商品档案同步");
try {
itemSyncService.syncItems();
log.info("定时任务商品档案同步执行完成");
} catch (Exception e) {
log.error("定时任务商品档案同步执行失败", e);
}
}
}
# 开发环境配置
server:
port: 8081
logging:
level:
root: INFO
cn.nhsoft.hanni: DEBUG
org.springframework.web: DEBUG
# 本地配置示例,复制为 application-local.yml 并填入真实值
# application-local.yml 已在 .gitignore 中,不会提交
lemon:
api:
access-token: your-lemon-access-token-here
hanni:
api:
sign-key: 8884EC617620ED67F4ECF164A29FC23D # 签名密钥,请向汉尼确认
# 生产环境配置 - MySQL 数据库
spring:
datasource:
url: jdbc:mysql://106.15.9.190:3306/lemeng?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: '2ws$gsda%lp3tP!3s%d5r$ti3'
jpa:
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL8Dialect
server:
port: 8081
servlet:
context-path: /api
# 使用 MySQL 数据库时请添加: spring.profiles.active=prod
# 生产环境: java -jar app.jar --spring.profiles.active=prod
spring:
application:
name: hanni-external-api
datasource:
url: jdbc:h2:mem:hanni_external;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.H2Dialect
# 乐檬商品查询接口(RestTemplate,不使用 SDK)
lemon:
api:
base-url: https://cloud.nhsoft.cn
item-find-url: /api/nhsoft.amazon.basic.item.find/v2
access-token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOltudWxsXSwidXNlcl9uYW1lIjoiMTM4MTkwNzY4MDYiLCJzY29wZSI6WyJyZWFkIl0sIk5oc29mdC1NZXJjaGFudC1UeXBlIjoiQU1BIiwiTmhzb2Z0LUFsbHBheS1BcHAtSWQiOiJlYmY5YzA4NTVlYzA0OTRmYWQ4ODM0ZmQwNDI1ODE4MCIsIk5oc29mdC1Cb29rLUNvZGUiOiI2NTA2OCIsImV4cCI6MTc3MzgxMzcwNSwianRpIjoiYjIwMmJiZDctZDcyZC00ZTMzLTg3ZWEtZDAyMWI2ODZhNDAyIiwiTmhzb2Z0LU1lcmNoYW50LUlkIjoiNjUwNjgiLCJjbGllbnRfaWQiOiI2OTJlOTIyNzRlYWM0NTFkODI5NzdhOTI4YzgxM2E2MiJ9.BIW3ePaX308_UItO07jJ_2H9oQG3WhwB9jGF8lClEvEDuJMMgRIIOMcUj4cMV8MmgkkPWqeHMStDDD6zsvHQaRPNSIC8q8mBAr9H79j1i9PTbO-RkuSIOfF5spAiU5wz7Asa8yuWMGdYVh5zjEKmkuAWBE-hf4UUACTtu8Se7_kTC2ZQO7GxA0u3mdessEtjAFYSGyY7uMqcNfZOwIgIlQSMENebaPXz3wzmCcO_RxR11IH89ea3xHDJbE7VJN7vBHQjGO0hM68FWsdbC2PhfsWR2oBXcRy9DVJxZ0sLFwkvPxXnLiqn_DAZ-bp-1l-ZHKi-PLvZW4jsyYw168DPag
auth-type: bearer
# 乐檬 OAuth(RestTemplate,无 SDK):授权回调、刷新 token 使用
lemeng:
auth-server-url: https://cloud.nhsoft.cn/authserver
redirect-url: http://127.0.0.1:8081/api/auth/code
app:
system-book-code: ${SYSTEM_BOOK_CODE:65068}
app-id: ${APP_ID:692e92274eac451d82977a928c813a62}
app-secret: ${APP_SECRET:2194d3e1a3e5482ca80e0212d2009585}
# 汉尼系统接口配置
# receiveGoods: 新增商品 updateGoods: 变更商品更新(不配置则与 receive-goods-url 相同)
# 签名算法:action=xxx&data=xxx&key=密钥,取MD5
hanni:
api:
receive-goods-url: http://ycj.hannikj.cn:8777/api/ycjhandler.ashx
update-goods-url: # 可选,变更时调用的更新接口
sign-key: 8884EC617620ED67F4ECF164A29FC23D # 签名密钥
# 定时任务配置
task:
item-sync:
enabled: true
cron: "0 0 0 * * ?" # 每天凌晨 00:00:00
token-refresh:
enabled: true # 乐檬 token 定时刷新,expires_in 约 3600 秒,每 50 分钟刷新
# Swagger 配置
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
# Actuator 健康检查
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-details: when-authorized
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