Commit f180b5d0 by yangxianglong

自采联营门店系统开发

parent 25c521e9
...@@ -14,8 +14,8 @@ import org.springframework.scheduling.annotation.EnableScheduling; ...@@ -14,8 +14,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*/ */
@EnableScheduling @EnableScheduling
@SpringBootApplication @SpringBootApplication
@EntityScan({"cn.nhsoft.hanni.item"}) @EntityScan({"cn.nhsoft.hanni.item", "cn.nhsoft.hanni.lemeng"})
@EnableJpaRepositories({"cn.nhsoft.hanni.item"}) @EnableJpaRepositories({"cn.nhsoft.hanni.item", "cn.nhsoft.hanni.lemeng.repository"})
public class HanniExternalApiApplication { public class HanniExternalApiApplication {
public static void main(String[] args) { public static void main(String[] args) {
......
package cn.nhsoft.hanni.item.controller; package cn.nhsoft.hanni.item.controller;
import cn.nhsoft.hanni.common.Result; import cn.nhsoft.hanni.common.Result;
import cn.nhsoft.hanni.item.dto.ItemSyncDetailPageDTO;
import cn.nhsoft.hanni.item.dto.ItemSyncLogPageDTO; import cn.nhsoft.hanni.item.dto.ItemSyncLogPageDTO;
import cn.nhsoft.hanni.item.model.ItemSyncLog; import cn.nhsoft.hanni.item.model.ItemSyncLog;
import cn.nhsoft.hanni.item.repository.ItemSyncLogRepository; import cn.nhsoft.hanni.item.repository.ItemSyncLogRepository;
...@@ -13,6 +14,7 @@ import org.springframework.data.domain.Page; ...@@ -13,6 +14,7 @@ import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
...@@ -92,4 +94,13 @@ public class ItemController { ...@@ -92,4 +94,13 @@ public class ItemController {
ItemSyncLogPageDTO dto = new ItemSyncLogPageDTO(resultPage.getContent(), resultPage.getTotalElements()); ItemSyncLogPageDTO dto = new ItemSyncLogPageDTO(resultPage.getContent(), resultPage.getTotalElements());
return Result.success(dto); return Result.success(dto);
} }
@Operation(summary = "商品同步明细", description = "按同步日志 ID 分页查询本次任务每条商品的 ADD/UPDATE/SKIP 记录")
@GetMapping("/sync-logs/{logId}/details")
public Result<ItemSyncDetailPageDTO> getSyncLogDetails(
@Parameter(description = "同步日志 id") @PathVariable("logId") Long logId,
@Parameter(description = "页码") @RequestParam(defaultValue = "0") Integer page,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "50") Integer size) {
return Result.success(itemSyncService.getSyncDetails(logId, page, size));
}
} }
package cn.nhsoft.hanni.item.converter; package cn.nhsoft.hanni.item.converter;
import cn.nhsoft.hanni.item.dto.HanniItemDTO; import cn.nhsoft.hanni.item.dto.HanniItemDTO;
import cn.nhsoft.hanni.item.dto.LemonItemRow;
import cn.nhsoft.hanni.item.util.LemonItemResponseParser; import cn.nhsoft.hanni.item.util.LemonItemResponseParser;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
...@@ -56,6 +58,34 @@ public class LemonToHanniItemConverter { ...@@ -56,6 +58,34 @@ public class LemonToHanniItemConverter {
} }
} }
/**
* 转换为带乐檬最后修改时间的行(同步、跳过逻辑用)
*/
public List<LemonItemRow> convertToRows(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<LemonItemRow> result = new ArrayList<>();
for (JsonNode node : itemsNode) {
LemonItemRow row = convertSingleToRow(node);
if (row != null) {
result.add(row);
}
}
return result;
} catch (Exception e) {
log.error("乐檬商品数据转换失败", e);
return new ArrayList<>();
}
}
private HanniItemDTO convertSingle(JsonNode node) { private HanniItemDTO convertSingle(JsonNode node) {
try { try {
Map<String, Object> map = objectMapper.convertValue(node, new TypeReference<Map<String, Object>>() {}); Map<String, Object> map = objectMapper.convertValue(node, new TypeReference<Map<String, Object>>() {});
...@@ -69,6 +99,39 @@ public class LemonToHanniItemConverter { ...@@ -69,6 +99,39 @@ 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);
} catch (Exception e) {
log.warn("单条商品转换失败: {}", node, e);
return null;
}
}
/**
* 从乐檬商品对象解析「最后修改时间」字符串(多字段兼容)
*/
private String extractLemonLastModified(Map<String, Object> m) {
String[] keys = {
"item_modify_time", "item_update_time", "last_modify_time", "last_update_time",
"modify_time", "update_time", "gmt_modified", "item_last_modify_time",
"last_download_time", "item_last_update_time"
};
for (String k : keys) {
Object v = m.get(k);
if (v != null && StringUtils.hasText(v.toString())) {
return v.toString().trim();
}
}
return null;
}
/** /**
* 养馋记商品过滤:是否停购=否,且不为淘汰、删除的商品才传入汉尼 * 养馋记商品过滤:是否停购=否,且不为淘汰、删除的商品才传入汉尼
*/ */
......
package cn.nhsoft.hanni.item.dto;
import cn.nhsoft.hanni.item.model.ItemSyncDetail;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ItemSyncDetailPageDTO {
private List<ItemSyncDetail> records;
private long total;
}
package cn.nhsoft.hanni.item.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 乐檬单条商品转换结果 + 乐檬侧最后修改时间(用于跳过未变化商品)
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LemonItemRow {
private HanniItemDTO item;
/**
* 乐檬商品最后修改时间原始字符串(与快照表按字符串相等比较,字段名兼容见转换器)
*/
private String lemonLastModified;
}
package cn.nhsoft.hanni.item.model;
import lombok.Data;
import javax.persistence.*;
import java.util.Date;
/**
* 单次同步任务中的商品级明细(可后台分页查询)
*/
@Data
@Entity
@Table(name = "item_sync_detail", indexes = {
@Index(name = "idx_detail_sync_log_id", columnList = "sync_log_id"),
@Index(name = "idx_detail_log_barcode", columnList = "sync_log_id,barcode")
})
public class ItemSyncDetail {
public static final String ACTION_ADD = "ADD";
public static final String ACTION_UPDATE = "UPDATE";
public static final String ACTION_SKIP_TIME = "SKIP_TIME";
public static final String ACTION_SKIP_NO_BARCODE = "SKIP_NO_BARCODE";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "sync_log_id", nullable = false)
private Long syncLogId;
@Column(name = "barcode", length = 64)
private String barcode;
@Column(name = "gid")
private Long gid;
@Column(name = "name", length = 512)
private String name;
/** ADD / UPDATE / SKIP_TIME / SKIP_NO_BARCODE */
@Column(name = "action", length = 32)
private String action;
/** 本次乐檬返回的修改时间 */
@Column(name = "lemon_last_modified", length = 64)
private String lemonLastModified;
/** 快照中上次记录的修改时间(SKIP_TIME 时有值) */
@Column(name = "stored_lemon_modified", length = 64)
private String storedLemonModified;
@Column(name = "success")
private Boolean success;
@Column(name = "remark", length = 500)
private String remark;
@Column(name = "created_at")
@Temporal(TemporalType.TIMESTAMP)
private Date createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = new Date();
}
}
}
package cn.nhsoft.hanni.item.model;
import lombok.Data;
import javax.persistence.*;
import java.math.BigDecimal;
import java.util.Date;
/**
* 商品同步快照:仅存汉尼所需字段 + 乐檬最后修改时间;时间未变则可跳过推汉尼
*/
@Data
@Entity
@Table(name = "item_sync_snapshot", indexes = {
@Index(name = "idx_snapshot_lemon_mod", columnList = "lemon_last_modified")
})
public class ItemSyncSnapshot {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "barcode", length = 64, nullable = false, unique = true)
private String barcode;
@Column(name = "gid")
private Long gid;
@Column(name = "name", length = 512)
private String name;
@Column(name = "spec", length = 255)
private String spec;
@Column(name = "saleunit", length = 64)
private String saleunit;
@Column(name = "price", precision = 18, scale = 4)
private BigDecimal price;
@Column(name = "retailprice", precision = 18, scale = 4)
private BigDecimal retailprice;
@Column(name = "parea", length = 255)
private String parea;
@Column(name = "isweight", length = 8)
private String isweight;
@Column(name = "packratio", length = 64)
private String packratio;
@Column(name = "grade", length = 64)
private String grade;
@Column(name = "model", length = 128)
private String model;
/** 乐檬侧最后修改时间(接口返回原始字符串,与本次拉取比较) */
@Column(name = "lemon_last_modified", length = 64)
private String lemonLastModified;
@Column(name = "updated_at")
@Temporal(TemporalType.TIMESTAMP)
private Date updatedAt;
@PrePersist
@PreUpdate
public void touch() {
this.updatedAt = new Date();
}
}
package cn.nhsoft.hanni.item.repository;
import cn.nhsoft.hanni.item.model.ItemSyncDetail;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ItemSyncDetailRepository extends JpaRepository<ItemSyncDetail, Long> {
Page<ItemSyncDetail> findBySyncLogIdOrderByIdAsc(Long syncLogId, Pageable pageable);
}
package cn.nhsoft.hanni.item.repository;
import cn.nhsoft.hanni.item.model.ItemSyncSnapshot;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ItemSyncSnapshotRepository extends JpaRepository<ItemSyncSnapshot, Long> {
Optional<ItemSyncSnapshot> findByBarcode(String barcode);
}
package cn.nhsoft.hanni.item.service; package cn.nhsoft.hanni.item.service;
import cn.nhsoft.hanni.item.dto.ItemSyncDetailPageDTO;
import cn.nhsoft.hanni.item.dto.LemonItemFindRequest; import cn.nhsoft.hanni.item.dto.LemonItemFindRequest;
import java.util.List;
/** /**
* 商品档案同步服务接口 * 商品档案同步服务接口
* <p> * <p>
...@@ -37,4 +36,9 @@ public interface ItemSyncService { ...@@ -37,4 +36,9 @@ public interface ItemSyncService {
* @param request 乐檬商品档案查询参数(与 nhsoft.amazon.basic.item.find 一致) * @param request 乐檬商品档案查询参数(与 nhsoft.amazon.basic.item.find 一致)
*/ */
void syncItemsWithParams(LemonItemFindRequest request); void syncItemsWithParams(LemonItemFindRequest request);
/**
* 分页查询某次同步任务的商品明细
*/
ItemSyncDetailPageDTO getSyncDetails(Long syncLogId, int page, int size);
} }
...@@ -2,26 +2,38 @@ package cn.nhsoft.hanni.item.service.impl; ...@@ -2,26 +2,38 @@ package cn.nhsoft.hanni.item.service.impl;
import cn.nhsoft.hanni.client.HanniApiClient; import cn.nhsoft.hanni.client.HanniApiClient;
import cn.nhsoft.hanni.client.LemonApiClient; 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.converter.LemonToHanniItemConverter;
import cn.nhsoft.hanni.item.dto.HanniItemDTO; import cn.nhsoft.hanni.item.dto.HanniItemDTO;
import cn.nhsoft.hanni.item.dto.ItemSyncDetailPageDTO;
import cn.nhsoft.hanni.item.dto.LemonItemFindRequest; import cn.nhsoft.hanni.item.dto.LemonItemFindRequest;
import cn.nhsoft.hanni.item.model.ItemSyncCache; import cn.nhsoft.hanni.item.dto.LemonItemRow;
import cn.nhsoft.hanni.item.model.ItemSyncDetail;
import cn.nhsoft.hanni.item.model.ItemSyncLog; import cn.nhsoft.hanni.item.model.ItemSyncLog;
import cn.nhsoft.hanni.item.repository.ItemSyncCacheRepository; import cn.nhsoft.hanni.item.model.ItemSyncSnapshot;
import cn.nhsoft.hanni.item.repository.ItemSyncDetailRepository;
import cn.nhsoft.hanni.item.repository.ItemSyncLogRepository; import cn.nhsoft.hanni.item.repository.ItemSyncLogRepository;
import cn.nhsoft.hanni.item.repository.ItemSyncSnapshotRepository;
import cn.nhsoft.hanni.item.service.ItemSyncService; import cn.nhsoft.hanni.item.service.ItemSyncService;
import cn.nhsoft.hanni.item.util.LemonItemResponseParser; import cn.nhsoft.hanni.item.util.LemonItemResponseParser;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.PostConstruct;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.text.SimpleDateFormat; import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
...@@ -44,11 +56,24 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -44,11 +56,24 @@ public class ItemSyncServiceImpl implements ItemSyncService {
private ItemSyncLogRepository itemSyncLogRepository; private ItemSyncLogRepository itemSyncLogRepository;
@Resource @Resource
private ItemSyncCacheRepository itemSyncCacheRepository; private ItemSyncSnapshotRepository itemSyncSnapshotRepository;
@Resource
private ItemSyncDetailRepository itemSyncDetailRepository;
@Resource @Resource
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@Resource
private PlatformTransactionManager platformTransactionManager;
private TransactionTemplate transactionTemplate;
@PostConstruct
public void initTx() {
this.transactionTemplate = new TransactionTemplate(platformTransactionManager);
}
@FunctionalInterface @FunctionalInterface
private interface SyncLogWork { private interface SyncLogWork {
void execute(ItemSyncLog syncLog) throws Exception; void execute(ItemSyncLog syncLog) throws Exception;
...@@ -82,16 +107,19 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -82,16 +107,19 @@ public class ItemSyncServiceImpl implements ItemSyncService {
if (request.getPageSize() == null) { if (request.getPageSize() == null) {
request.setPageSize(100); request.setPageSize(100);
} }
// 与定时任务一致:按条件多页拉取直至不足一页
doSyncIncremental(request, "MANUAL"); doSyncIncremental(request, "MANUAL");
} }
/** @Override
* 增量/手动同步:分页拉取,复制完整查询条件 public ItemSyncDetailPageDTO getSyncDetails(Long syncLogId, int page, int size) {
*/ PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"));
Page<ItemSyncDetail> p = itemSyncDetailRepository.findBySyncLogIdOrderByIdAsc(syncLogId, pageable);
return new ItemSyncDetailPageDTO(p.getContent(), p.getTotalElements());
}
private void doSyncIncremental(LemonItemFindRequest request, String syncType) { private void doSyncIncremental(LemonItemFindRequest request, String syncType) {
runWithSyncLog(syncType, syncLog -> { runWithSyncLog(syncType, syncLog -> {
List<HanniItemDTO> allHanniItems = new ArrayList<>(); List<LemonItemRow> allRows = new ArrayList<>();
int pageNo = request.getPageNo() != null ? request.getPageNo() : 1; int pageNo = request.getPageNo() != null ? request.getPageNo() : 1;
int pageSize = request.getPageSize() != null ? request.getPageSize() : 100; int pageSize = request.getPageSize() != null ? request.getPageSize() : 100;
int lemengTotal = 0; int lemengTotal = 0;
...@@ -100,14 +128,14 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -100,14 +128,14 @@ public class ItemSyncServiceImpl implements ItemSyncService {
LemonItemFindRequest pageReq = copyPageRequest(request, pageNo, pageSize); LemonItemFindRequest pageReq = copyPageRequest(request, pageNo, pageSize);
log.info("从乐檬获取商品档案,pageNo={}, lastDownloadTime={}", pageNo, request.getLastDownloadTime()); log.info("从乐檬获取商品档案,pageNo={}, lastDownloadTime={}", pageNo, request.getLastDownloadTime());
String lemonItems = lemonApiClient.findItems(pageReq); String lemonItems = lemonApiClient.findItems(pageReq);
List<HanniItemDTO> pageItems = lemonToHanniItemConverter.convert(lemonItems); List<LemonItemRow> pageRows = lemonToHanniItemConverter.convertToRows(lemonItems);
int count = parseLemengItemCount(lemonItems); int count = parseLemengItemCount(lemonItems);
lemengTotal += count; lemengTotal += count;
if (pageItems == null || pageItems.isEmpty()) { if (pageRows == null || pageRows.isEmpty()) {
break; break;
} }
allHanniItems.addAll(pageItems); allRows.addAll(pageRows);
if (count < pageSize) { if (count < pageSize) {
break; break;
} }
...@@ -115,7 +143,10 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -115,7 +143,10 @@ public class ItemSyncServiceImpl implements ItemSyncService {
} }
syncLog.setLemengCount(lemengTotal); syncLog.setLemengCount(lemengTotal);
processAndSyncToHanniFromList(allHanniItems, syncLog); transactionTemplate.execute(status -> {
processAndSyncToHanniFromList(allRows, syncLog);
return null;
});
}); });
} }
...@@ -130,8 +161,10 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -130,8 +161,10 @@ public class ItemSyncServiceImpl implements ItemSyncService {
private void runWithSyncLog(String syncType, SyncLogWork work) { private void runWithSyncLog(String syncType, SyncLogWork work) {
ItemSyncLog syncLog = new ItemSyncLog(); ItemSyncLog syncLog = new ItemSyncLog();
syncLog.setSyncType(syncType); syncLog.setSyncType(syncType);
long startMs = System.currentTimeMillis();
syncLog.setStartTime(new Date()); syncLog.setStartTime(new Date());
syncLog.setStatus("RUNNING");
syncLog = itemSyncLogRepository.saveAndFlush(syncLog);
long startMs = System.currentTimeMillis();
try { try {
work.execute(syncLog); work.execute(syncLog);
} catch (Exception e) { } catch (Exception e) {
...@@ -141,92 +174,179 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -141,92 +174,179 @@ public class ItemSyncServiceImpl implements ItemSyncService {
} finally { } finally {
syncLog.setEndTime(new Date()); syncLog.setEndTime(new Date());
syncLog.setDurationMs(System.currentTimeMillis() - startMs); syncLog.setDurationMs(System.currentTimeMillis() - startMs);
if ("RUNNING".equals(syncLog.getStatus())) {
syncLog.setStatus("FAIL");
if (syncLog.getErrorMsg() == null || syncLog.getErrorMsg().isEmpty()) {
syncLog.setErrorMsg("同步未完成");
}
}
itemSyncLogRepository.save(syncLog); itemSyncLogRepository.save(syncLog);
} }
} }
private void processAndSyncToHanniFromList(List<HanniItemDTO> hanniItems, ItemSyncLog syncLog) { private void processAndSyncToHanniFromList(List<LemonItemRow> rows, ItemSyncLog syncLog) {
syncLog.setHanniCount(hanniItems != null ? hanniItems.size() : 0); Long logId = syncLog.getId();
if (rows == null || rows.isEmpty()) {
if (hanniItems == null || hanniItems.isEmpty()) { syncLog.setHanniCount(0);
log.warn("转换后无有效商品数据,跳过同步");
syncLog.setStatus("SKIP"); syncLog.setStatus("SKIP");
syncLog.setErrorMsg("转换后无有效商品数据"); syncLog.setErrorMsg("转换后无有效商品数据");
return; return;
} }
log.info("转换得到 {} 条汉尼格式商品(已过滤停购/淘汰/删除)", hanniItems.size());
syncLog.setHanniCount(rows.size());
log.info("待处理 {} 条商品(按乐檬最后修改时间跳过未变化)", rows.size());
List<ItemSyncDetail> details = new ArrayList<>();
List<HanniItemDTO> toAdd = new ArrayList<>(); List<HanniItemDTO> toAdd = new ArrayList<>();
List<HanniItemDTO> toUpdate = new ArrayList<>(); List<HanniItemDTO> toUpdate = new ArrayList<>();
for (HanniItemDTO item : hanniItems) { List<LemonItemRow> addRows = new ArrayList<>();
List<LemonItemRow> updateRows = new ArrayList<>();
Map<String, ItemSyncDetail> addDetailByBc = new HashMap<>();
Map<String, ItemSyncDetail> updateDetailByBc = new HashMap<>();
for (LemonItemRow row : rows) {
HanniItemDTO item = row.getItem();
if (item == null) {
continue;
}
String barcode = item.getBarcode(); String barcode = item.getBarcode();
if (barcode == null || barcode.isEmpty()) {
if (!StringUtils.hasText(barcode)) {
ItemSyncDetail d = new ItemSyncDetail();
d.setSyncLogId(logId);
d.setGid(item.getGid());
d.setName(item.getName());
d.setAction(ItemSyncDetail.ACTION_SKIP_NO_BARCODE);
d.setSuccess(true);
d.setRemark("缺少条码,不同步");
details.add(d);
continue; continue;
} }
String hash = computeItemHash(item);
Optional<ItemSyncCache> cached = itemSyncCacheRepository.findByBarcode(barcode); Optional<ItemSyncSnapshot> snapOpt = itemSyncSnapshotRepository.findByBarcode(barcode);
if (!cached.isPresent()) { String curMod = normalizeMod(row.getLemonLastModified());
toAdd.add(item); boolean canSkipByTime = StringUtils.hasText(curMod) && snapOpt.isPresent()
} else if (!hash.equals(cached.get().getContentHash())) { && curMod.equals(normalizeMod(snapOpt.get().getLemonLastModified()));
if (canSkipByTime) {
ItemSyncDetail d = new ItemSyncDetail();
d.setSyncLogId(logId);
d.setBarcode(barcode);
d.setGid(item.getGid());
d.setName(item.getName());
d.setAction(ItemSyncDetail.ACTION_SKIP_TIME);
d.setLemonLastModified(row.getLemonLastModified());
d.setStoredLemonModified(snapOpt.get().getLemonLastModified());
d.setSuccess(true);
d.setRemark("乐檬最后修改时间未变化,跳过推送汉尼");
details.add(d);
continue;
}
ItemSyncDetail d = new ItemSyncDetail();
d.setSyncLogId(logId);
d.setBarcode(barcode);
d.setGid(item.getGid());
d.setName(item.getName());
d.setLemonLastModified(row.getLemonLastModified());
if (snapOpt.isPresent()) {
d.setAction(ItemSyncDetail.ACTION_UPDATE);
d.setStoredLemonModified(snapOpt.get().getLemonLastModified());
toUpdate.add(item); toUpdate.add(item);
updateRows.add(row);
updateDetailByBc.put(barcode, d);
} else {
d.setAction(ItemSyncDetail.ACTION_ADD);
toAdd.add(item);
addRows.add(row);
addDetailByBc.put(barcode, d);
} }
details.add(d);
} }
StringBuilder responseBuilder = new StringBuilder(); StringBuilder responseBuilder = new StringBuilder();
if (!toAdd.isEmpty()) { if (!toAdd.isEmpty()) {
log.info("新增 {} 条商品到汉尼", toAdd.size()); log.info("新增 {} 条商品到汉尼", toAdd.size());
String r = hanniApiClient.saveItems(toAdd); String r = hanniApiClient.saveItems(toAdd);
if (r != null) { if (r != null) {
responseBuilder.append("add:").append(r); responseBuilder.append("add:").append(r);
} }
String shortR = truncateStr(r, 500);
for (LemonItemRow rr : addRows) {
ItemSyncDetail d = addDetailByBc.get(rr.getItem().getBarcode());
if (d != null) {
d.setSuccess(true);
d.setRemark(shortR);
}
upsertSnapshot(rr);
}
} }
if (!toUpdate.isEmpty()) { if (!toUpdate.isEmpty()) {
log.info("更新 {} 条变更商品到汉尼", toUpdate.size()); log.info("更新 {} 条变更商品到汉尼", toUpdate.size());
String r = hanniApiClient.updateItems(toUpdate); String r = hanniApiClient.updateItems(toUpdate);
if (r != null) { if (r != null) {
responseBuilder.append(" update:").append(r); responseBuilder.append(" update:").append(r);
} }
} String shortR = truncateStr(r, 500);
for (LemonItemRow rr : updateRows) {
for (HanniItemDTO item : hanniItems) { ItemSyncDetail d = updateDetailByBc.get(rr.getItem().getBarcode());
String barcode = item.getBarcode(); if (d != null) {
if (barcode == null || barcode.isEmpty()) { d.setSuccess(true);
continue; d.setRemark(shortR);
}
upsertSnapshot(rr);
} }
String hash = computeItemHash(item);
ItemSyncCache cache = itemSyncCacheRepository.findByBarcode(barcode)
.orElse(new ItemSyncCache());
cache.setBarcode(barcode);
cache.setContentHash(hash);
itemSyncCacheRepository.save(cache);
} }
saveDetailsInChunks(details);
syncLog.setStatus("SUCCESS"); syncLog.setStatus("SUCCESS");
syncLog.setHanniResponse(truncateResponse(responseBuilder.toString())); syncLog.setHanniResponse(truncateStr(responseBuilder.toString(), 2000));
} }
private int parseLemengItemCount(String lemonJson) { private static String normalizeMod(String s) {
return LemonItemResponseParser.countItemsInResponse(lemonJson, objectMapper); return s == null ? "" : s.trim();
} }
private String truncateResponse(String response) { private void upsertSnapshot(LemonItemRow row) {
if (response == null) { HanniItemDTO it = row.getItem();
return null; String bc = it.getBarcode();
ItemSyncSnapshot s = itemSyncSnapshotRepository.findByBarcode(bc).orElse(new ItemSyncSnapshot());
s.setBarcode(bc);
s.setGid(it.getGid());
s.setName(it.getName());
s.setSpec(it.getSpec());
s.setSaleunit(it.getSaleunit());
s.setPrice(it.getPrice());
s.setRetailprice(it.getRetailprice());
s.setParea(it.getParea());
s.setIsweight(it.getIsweight());
s.setPackratio(it.getPackratio());
s.setGrade(it.getGrade());
s.setModel(it.getModel());
s.setLemonLastModified(row.getLemonLastModified());
itemSyncSnapshotRepository.save(s);
}
private void saveDetailsInChunks(List<ItemSyncDetail> details) {
int batch = 200;
for (int i = 0; i < details.size(); i += batch) {
int end = Math.min(i + batch, details.size());
itemSyncDetailRepository.saveAll(details.subList(i, end));
} }
return response.length() > 2000 ? response.substring(0, 2000) + "..." : response;
} }
private String computeItemHash(HanniItemDTO item) { private int parseLemengItemCount(String lemonJson) {
String str = String.format("%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s", return LemonItemResponseParser.countItemsInResponse(lemonJson, objectMapper);
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) { private String truncateStr(String response, int maxLen) {
return o == null ? "" : o.toString(); if (response == null) {
return null;
}
return response.length() > maxLen ? response.substring(0, maxLen) + "..." : response;
} }
@Override @Override
......
...@@ -3,7 +3,7 @@ package cn.nhsoft.hanni.lemeng.controller; ...@@ -3,7 +3,7 @@ package cn.nhsoft.hanni.lemeng.controller;
import cn.nhsoft.hanni.lemeng.client.LemengTokenRestClient; import cn.nhsoft.hanni.lemeng.client.LemengTokenRestClient;
import cn.nhsoft.hanni.lemeng.config.LemengProperties; import cn.nhsoft.hanni.lemeng.config.LemengProperties;
import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult; import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult;
import cn.nhsoft.hanni.lemeng.util.TokenHolder; import cn.nhsoft.hanni.lemeng.service.LemengTokenPersistService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
...@@ -32,7 +32,7 @@ public class LemengAuthController { ...@@ -32,7 +32,7 @@ public class LemengAuthController {
private LemengProperties lemengProperties; private LemengProperties lemengProperties;
@Resource @Resource
private TokenHolder tokenHolder; private LemengTokenPersistService lemengTokenPersistService;
@Operation(summary = "OAuth2 授权回调", description = "使用 code 换取 access_token 并写入 TokenHolder;无 code 时跳转 Swagger") @Operation(summary = "OAuth2 授权回调", description = "使用 code 换取 access_token 并写入 TokenHolder;无 code 时跳转 Swagger")
@GetMapping("/code") @GetMapping("/code")
...@@ -61,7 +61,7 @@ public class LemengAuthController { ...@@ -61,7 +61,7 @@ public class LemengAuthController {
log.error("无法解析账套号,请配置 lemeng.app.system-book-code"); log.error("无法解析账套号,请配置 lemeng.app.system-book-code");
return "redirect:/swagger-ui.html?lemeng_auth=merchant"; return "redirect:/swagger-ui.html?lemeng_auth=merchant";
} }
tokenHolder.setToken(book, result.getAccessToken(), result.getRefreshToken()); lemengTokenPersistService.saveAndCache(book, result.getAccessToken(), result.getRefreshToken(), result.getExpiresIn());
log.info("OAuth 授权成功,账套: {}", book); log.info("OAuth 授权成功,账套: {}", book);
} catch (Exception e) { } catch (Exception e) {
log.error("乐檬 OAuth 获取 Token 失败", e); log.error("乐檬 OAuth 获取 Token 失败", e);
......
...@@ -4,7 +4,7 @@ import cn.nhsoft.hanni.common.Result; ...@@ -4,7 +4,7 @@ import cn.nhsoft.hanni.common.Result;
import cn.nhsoft.hanni.lemeng.client.LemengTokenRestClient; import cn.nhsoft.hanni.lemeng.client.LemengTokenRestClient;
import cn.nhsoft.hanni.lemeng.config.LemengProperties; import cn.nhsoft.hanni.lemeng.config.LemengProperties;
import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult; import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult;
import cn.nhsoft.hanni.lemeng.util.TokenHolder; import cn.nhsoft.hanni.lemeng.service.LemengTokenPersistService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
...@@ -34,7 +34,7 @@ public class LemengTokenController { ...@@ -34,7 +34,7 @@ public class LemengTokenController {
private LemengTokenRestClient lemengTokenRestClient; private LemengTokenRestClient lemengTokenRestClient;
@Resource @Resource
private TokenHolder tokenHolder; private LemengTokenPersistService lemengTokenPersistService;
@Resource @Resource
private LemengProperties lemengProperties; private LemengProperties lemengProperties;
...@@ -59,7 +59,7 @@ public class LemengTokenController { ...@@ -59,7 +59,7 @@ public class LemengTokenController {
if (!StringUtils.hasText(book)) { if (!StringUtils.hasText(book)) {
return Result.error("无法解析账套号,请配置 lemeng.app.system-book-code 或检查 token"); return Result.error("无法解析账套号,请配置 lemeng.app.system-book-code 或检查 token");
} }
tokenHolder.setToken(book, result.getAccessToken(), result.getRefreshToken()); lemengTokenPersistService.saveAndCache(book, result.getAccessToken(), result.getRefreshToken(), result.getExpiresIn());
return Result.success(result); return Result.success(result);
} catch (HttpClientErrorException e) { } catch (HttpClientErrorException e) {
HttpStatus status = e.getStatusCode(); HttpStatus status = e.getStatusCode();
...@@ -85,7 +85,7 @@ public class LemengTokenController { ...@@ -85,7 +85,7 @@ public class LemengTokenController {
} }
} }
@Operation(summary = "手动设置 Token", description = "将 access_token、refresh_token 写入内存 TokenHolder,供乐檬商品查询使用") @Operation(summary = "手动设置 Token", description = "将 access_token、refresh_token 写入数据库并同步到内存,供乐檬商品查询使用")
@PostMapping("/set") @PostMapping("/set")
public Result<String> setToken( public Result<String> setToken(
@Parameter(description = "账套号", required = true) @RequestParam String systemBookCode, @Parameter(description = "账套号", required = true) @RequestParam String systemBookCode,
...@@ -94,7 +94,7 @@ public class LemengTokenController { ...@@ -94,7 +94,7 @@ public class LemengTokenController {
if (!StringUtils.hasText(systemBookCode) || !StringUtils.hasText(accessToken) || !StringUtils.hasText(refreshToken)) { if (!StringUtils.hasText(systemBookCode) || !StringUtils.hasText(accessToken) || !StringUtils.hasText(refreshToken)) {
return Result.error("systemBookCode、accessToken、refreshToken 均不能为空"); return Result.error("systemBookCode、accessToken、refreshToken 均不能为空");
} }
tokenHolder.setToken(systemBookCode, accessToken, refreshToken); lemengTokenPersistService.saveAndCache(systemBookCode, accessToken, refreshToken);
log.info("手动设置 Token 成功,账套号: {}", systemBookCode); log.info("手动设置 Token 成功,账套号: {}", systemBookCode);
return Result.success(systemBookCode); return Result.success(systemBookCode);
} }
......
...@@ -3,6 +3,7 @@ package cn.nhsoft.hanni.lemeng.job; ...@@ -3,6 +3,7 @@ package cn.nhsoft.hanni.lemeng.job;
import cn.nhsoft.hanni.lemeng.client.LemengTokenRestClient; import cn.nhsoft.hanni.lemeng.client.LemengTokenRestClient;
import cn.nhsoft.hanni.lemeng.config.LemengProperties; import cn.nhsoft.hanni.lemeng.config.LemengProperties;
import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult; import cn.nhsoft.hanni.lemeng.dto.TokenRefreshResult;
import cn.nhsoft.hanni.lemeng.service.LemengTokenPersistService;
import cn.nhsoft.hanni.lemeng.util.TokenHolder; import cn.nhsoft.hanni.lemeng.util.TokenHolder;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
...@@ -28,6 +29,9 @@ public class LemengTokenRefreshJob { ...@@ -28,6 +29,9 @@ public class LemengTokenRefreshJob {
private TokenHolder tokenHolder; private TokenHolder tokenHolder;
@Resource @Resource
private LemengTokenPersistService lemengTokenPersistService;
@Resource
private LemengProperties lemengProperties; private LemengProperties lemengProperties;
@Scheduled(fixedDelay = 50 * 60 * 1000, initialDelay = 60 * 1000) @Scheduled(fixedDelay = 50 * 60 * 1000, initialDelay = 60 * 1000)
...@@ -59,7 +63,7 @@ public class LemengTokenRefreshJob { ...@@ -59,7 +63,7 @@ public class LemengTokenRefreshJob {
if (!StringUtils.hasText(book)) { if (!StringUtils.hasText(book)) {
book = systemBookCode; book = systemBookCode;
} }
tokenHolder.setToken(book, result.getAccessToken(), result.getRefreshToken()); lemengTokenPersistService.saveAndCache(book, result.getAccessToken(), result.getRefreshToken(), result.getExpiresIn());
log.info("定时刷新 token 成功,账套: {}", book); log.info("定时刷新 token 成功,账套: {}", book);
} catch (Exception ex) { } catch (Exception ex) {
log.error("定时刷新账套 {} token 失败", systemBookCode, ex); log.error("定时刷新账套 {} token 失败", systemBookCode, ex);
......
package cn.nhsoft.hanni.lemeng.model;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
import java.util.Date;
/**
* 乐檬 OAuth Token 持久化(access / refresh),按账套唯一
*/
@Getter
@Setter
@Entity
@Table(name = "lemeng_oauth_token")
public class LemengOAuthToken {
@Id
@Column(name = "system_book_code", length = 64, nullable = false)
private String systemBookCode;
@Column(name = "access_token", columnDefinition = "TEXT", nullable = false)
private String accessToken;
@Column(name = "refresh_token", columnDefinition = "TEXT")
private String refreshToken;
/**
* access_token 预计过期时间(根据 OAuth 返回的 expires_in 推算,可能为空)
*/
@Column(name = "expires_at")
@Temporal(TemporalType.TIMESTAMP)
private Date expiresAt;
@Column(name = "updated_at")
@Temporal(TemporalType.TIMESTAMP)
private Date updatedAt;
@PrePersist
@PreUpdate
public void touchUpdatedAt() {
this.updatedAt = new Date();
}
}
package cn.nhsoft.hanni.lemeng.repository;
import cn.nhsoft.hanni.lemeng.model.LemengOAuthToken;
import org.springframework.data.jpa.repository.JpaRepository;
/**
* 乐檬 OAuth Token 表
*/
public interface LemengOAuthTokenRepository extends JpaRepository<LemengOAuthToken, String> {
}
package cn.nhsoft.hanni.lemeng.service;
import cn.nhsoft.hanni.lemeng.model.LemengOAuthToken;
import cn.nhsoft.hanni.lemeng.repository.LemengOAuthTokenRepository;
import cn.nhsoft.hanni.lemeng.util.TokenHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
/**
* 乐檬 Token 数据库持久化,并与内存 {@link TokenHolder} 同步。
* <p>启动时从库加载到内存;OAuth / 手动设置 / 定时刷新写入库并更新缓存。</p>
*/
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
@Service
public class LemengTokenPersistService implements ApplicationRunner {
@Resource
private LemengOAuthTokenRepository lemengOAuthTokenRepository;
@Resource
private TokenHolder tokenHolder;
@Override
@Transactional(readOnly = true)
public void run(ApplicationArguments args) {
reloadFromDatabaseIntoMemory();
}
/**
* 从数据库加载全部账套 Token 到 {@link TokenHolder}
*/
@Transactional(readOnly = true)
public void reloadFromDatabaseIntoMemory() {
List<LemengOAuthToken> all = lemengOAuthTokenRepository.findAll();
int loaded = 0;
for (LemengOAuthToken row : all) {
if (row != null && StringUtils.hasText(row.getSystemBookCode()) && StringUtils.hasText(row.getAccessToken())) {
tokenHolder.setToken(row.getSystemBookCode(), row.getAccessToken(), row.getRefreshToken());
loaded++;
}
}
log.info("乐檬 OAuth Token 已从数据库加载到内存,记录数: {},有效账套: {}", all.size(), loaded);
}
/**
* 持久化并刷新内存缓存
*
* @param expiresIn OAuth 返回的过期秒数,可为 null
*/
@Transactional
public void saveAndCache(String systemBookCode, String accessToken, String refreshToken, Integer expiresIn) {
if (!StringUtils.hasText(systemBookCode) || !StringUtils.hasText(accessToken)) {
return;
}
LemengOAuthToken entity = lemengOAuthTokenRepository.findById(systemBookCode).orElse(new LemengOAuthToken());
entity.setSystemBookCode(systemBookCode);
entity.setAccessToken(accessToken);
entity.setRefreshToken(StringUtils.hasText(refreshToken) ? refreshToken : null);
if (expiresIn != null && expiresIn > 0) {
entity.setExpiresAt(new Date(System.currentTimeMillis() + expiresIn * 1000L));
}
lemengOAuthTokenRepository.save(entity);
tokenHolder.setToken(systemBookCode, accessToken, refreshToken);
}
/**
* 无 expires_in 时持久化(如手动 set)
*/
@Transactional
public void saveAndCache(String systemBookCode, String accessToken, String refreshToken) {
saveAndCache(systemBookCode, accessToken, refreshToken, null);
}
}
...@@ -8,8 +8,9 @@ import java.util.Map; ...@@ -8,8 +8,9 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* 内存 Token 存储,供 RestTemplate 方式商品查询及定时刷新使用 * 进程内内存 Token 缓存,供 RestTemplate 商品查询及定时刷新使用。
* 通过 /lemeng/token/set 设置后,LemonApiClient 从此读取 * <p>写入请通过 {@link cn.nhsoft.hanni.lemeng.service.LemengTokenPersistService},以保证与数据库同步;
* 服务启动时会从表 {@code lemeng_oauth_token} 加载到此。</p>
*/ */
@Component @Component
public class TokenHolder { public class TokenHolder {
......
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