Commit a04c5989 by yangxianglong

自采联营门店系统开发

parent 6f6dffbd
package cn.nhsoft.hanni.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 商品同步等异步任务线程池
*/
@Configuration
@EnableAsync
public class AsyncConfig {
public static final String ITEM_SYNC_EXECUTOR = "itemSyncExecutor";
@Bean(name = ITEM_SYNC_EXECUTOR)
public Executor itemSyncExecutor() {
ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(2);
ex.setMaxPoolSize(8);
ex.setQueueCapacity(50);
ex.setThreadNamePrefix("item-sync-");
ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(120);
ex.initialize();
return ex;
}
}
...@@ -35,16 +35,15 @@ public class ItemController { ...@@ -35,16 +35,15 @@ public class ItemController {
@Resource @Resource
private ItemSyncLogRepository itemSyncLogRepository; private ItemSyncLogRepository itemSyncLogRepository;
@Operation(summary = "商品档案同步", description = "从乐檬获取商品档案,加工后传入汉尼系统;字段变更时调用汉尼更新接口") @Operation(summary = "商品档案同步", description = "异步执行:立即返回 syncLogId,可在同步记录中轮询进度(RUNNING + progressMessage)")
@GetMapping("/sync") @GetMapping("/sync")
public Result<Void> syncItems() { public Result<Long> syncItems() {
itemSyncService.syncItems(); return Result.success(itemSyncService.syncItemsStart());
return Result.success();
} }
@Operation(summary = "按乐檬参数手动同步", description = "与定时任务相同:按条件多页拉取直至不足一页,再同步到汉尼") @Operation(summary = "按乐檬参数手动同步", description = "与定时任务相同:按条件多页拉取直至不足一页,再同步到汉尼")
@GetMapping("/sync/manual") @GetMapping("/sync/manual")
public Result<Void> syncItemsManual( public Result<Long> syncItemsManual(
@Parameter(description = "页码", required = true) @RequestParam(defaultValue = "1") Integer pageNo, @Parameter(description = "页码", required = true) @RequestParam(defaultValue = "1") Integer pageNo,
@Parameter(description = "分页大小(最大1000)", required = true) @RequestParam(defaultValue = "100") Integer pageSize, @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 = "最后修改时间(yyyy-MM-dd HH:mm:ss)") @RequestParam(required = false) String lastDownloadTime,
...@@ -54,10 +53,10 @@ public class ItemController { ...@@ -54,10 +53,10 @@ public class ItemController {
@Parameter(description = "是否过滤淘汰商品,默认 true(仅同步非淘汰)") @RequestParam(required = false) Boolean filterWeedOut, @Parameter(description = "是否过滤淘汰商品,默认 true(仅同步非淘汰)") @RequestParam(required = false) Boolean filterWeedOut,
@Parameter(description = "商品编码,逗号分隔") @RequestParam(required = false) String itemNums, @Parameter(description = "商品编码,逗号分隔") @RequestParam(required = false) String itemNums,
@Parameter(description = "商品代码,逗号分隔") @RequestParam(required = false) String itemCodes) { @Parameter(description = "商品代码,逗号分隔") @RequestParam(required = false) String itemCodes) {
itemSyncService.syncItemsWithParams(LemonItemFindRequestMapper.fromQuery( Long logId = itemSyncService.syncItemsWithParamsStart(LemonItemFindRequestMapper.fromQuery(
pageNo, pageSize, lastDownloadTime, itemType, itemCategoryCode, pageNo, pageSize, lastDownloadTime, itemType, itemCategoryCode,
filterSleep, filterWeedOut, itemNums, itemCodes)); filterSleep, filterWeedOut, itemNums, itemCodes));
return Result.success(); return Result.success(logId);
} }
@Operation(summary = "从乐檬获取商品档案", description = "调用乐檬 nhsoft.amazon.basic.item.find,仅查询当前页,不同步") @Operation(summary = "从乐檬获取商品档案", description = "调用乐檬 nhsoft.amazon.basic.item.find,仅查询当前页,不同步")
...@@ -95,12 +94,15 @@ public class ItemController { ...@@ -95,12 +94,15 @@ public class ItemController {
return Result.success(dto); return Result.success(dto);
} }
@Operation(summary = "商品同步明细", description = "按同步日志 ID 分页查询本次任务每条商品的 ADD/UPDATE/SKIP 记录") @Operation(summary = "商品同步明细", description = "按同步日志 ID 分页查询本次任务每条商品的 ADD/UPDATE/SKIP 记录;支持条码模糊、动作、成功状态筛选")
@GetMapping("/sync-logs/{logId}/details") @GetMapping("/sync-logs/{logId}/details")
public Result<ItemSyncDetailPageDTO> getSyncLogDetails( public Result<ItemSyncDetailPageDTO> getSyncLogDetails(
@Parameter(description = "同步日志 id") @PathVariable("logId") Long logId, @Parameter(description = "同步日志 id") @PathVariable("logId") Long logId,
@Parameter(description = "页码") @RequestParam(defaultValue = "0") Integer page, @Parameter(description = "页码") @RequestParam(defaultValue = "0") Integer page,
@Parameter(description = "每页条数") @RequestParam(defaultValue = "50") Integer size) { @Parameter(description = "每页条数") @RequestParam(defaultValue = "50") Integer size,
return Result.success(itemSyncService.getSyncDetails(logId, page, size)); @Parameter(description = "条码(模糊匹配,忽略大小写)") @RequestParam(required = false) String barcode,
@Parameter(description = "动作:ADD/UPDATE/SKIP_TIME/SKIP_NO_BARCODE") @RequestParam(required = false) String action,
@Parameter(description = "成功:true 仅成功,false 非成功(含未记录)") @RequestParam(required = false) Boolean success) {
return Result.success(itemSyncService.getSyncDetails(logId, page, size, barcode, action, success));
} }
} }
...@@ -112,7 +112,9 @@ public class LemonToHanniItemConverter { ...@@ -112,7 +112,9 @@ public class LemonToHanniItemConverter {
* 从乐檬商品对象解析「最后修改时间」字符串(多字段兼容) * 从乐檬商品对象解析「最后修改时间」字符串(多字段兼容)
*/ */
private String extractLemonLastModified(Map<String, Object> m) { private String extractLemonLastModified(Map<String, Object> m) {
// 乐檬 item.find 常见为 item_last_edit_time,须优先匹配
String[] keys = { String[] keys = {
"item_last_edit_time",
"item_modify_time", "item_update_time", "last_modify_time", "last_update_time", "item_modify_time", "item_update_time", "last_modify_time", "last_update_time",
"modify_time", "update_time", "gmt_modified", "item_last_modify_time", "modify_time", "update_time", "gmt_modified", "item_last_modify_time",
"last_download_time", "item_last_update_time" "last_download_time", "item_last_update_time"
...@@ -126,6 +128,43 @@ public class LemonToHanniItemConverter { ...@@ -126,6 +128,43 @@ public class LemonToHanniItemConverter {
return null; return null;
} }
/**
* 等级:直段字段或 pos_item_grades[0] 子对象
*/
@SuppressWarnings("unchecked")
private String extractGrade(Map<String, Object> m) {
String s = getStr(m, "grade", "item_grade", "item_grade_name");
if (StringUtils.hasText(s)) {
return s;
}
Object pg = m.get("pos_item_grades");
if (pg instanceof List && !((List<?>) pg).isEmpty()) {
Object first = ((List<?>) pg).get(0);
if (first instanceof Map) {
Map<String, Object> gm = (Map<String, Object>) first;
Object v = gm.get("grade_name");
if (v == null) {
v = gm.get("name");
}
if (v != null && StringUtils.hasText(v.toString())) {
return v.toString().trim();
}
}
}
return "";
}
/**
* 型号:乐檬多为 item_model;无则可用商品代码 item_code 兜底
*/
private String extractModel(Map<String, Object> m) {
String s = getStr(m, "model", "item_model", "item_model_no", "product_model");
if (StringUtils.hasText(s)) {
return s;
}
return getStr(m, "item_code");
}
private HanniItemDTO convertFromMap(Map<String, Object> m) { private HanniItemDTO convertFromMap(Map<String, Object> m) {
HanniItemDTO dto = new HanniItemDTO(); HanniItemDTO dto = new HanniItemDTO();
dto.setBarcode(getStr(m, "item_barcode", "barcode")); dto.setBarcode(getStr(m, "item_barcode", "barcode"));
...@@ -137,8 +176,8 @@ public class LemonToHanniItemConverter { ...@@ -137,8 +176,8 @@ public class LemonToHanniItemConverter {
dto.setParea(getStr(m, "item_place", "parea", "place")); dto.setParea(getStr(m, "item_place", "parea", "place"));
dto.setIsweight(getBoolStr(m, "item_weight_flag", "isweight")); dto.setIsweight(getBoolStr(m, "item_weight_flag", "isweight"));
dto.setPackratio(getStr(m, "item_purchase_rate", "packratio", "purchase_rate")); dto.setPackratio(getStr(m, "item_purchase_rate", "packratio", "purchase_rate"));
dto.setGrade(getStr(m, "grade")); dto.setGrade(extractGrade(m));
dto.setModel(getStr(m, "model")); dto.setModel(extractModel(m));
dto.setGid(getLong(m, "item_num", "gid", "id")); dto.setGid(getLong(m, "item_num", "gid", "id"));
return dto; return dto;
} }
......
...@@ -35,12 +35,18 @@ public class ItemSyncLog { ...@@ -35,12 +35,18 @@ public class ItemSyncLog {
private Integer hanniCount; private Integer hanniCount;
/** /**
* 同步状态:SUCCESS/FAIL * 同步状态:SUCCESS/FAIL/SKIP/RUNNING
*/ */
@Column(name = "status", length = 16) @Column(name = "status", length = 16)
private String status; private String status;
/** /**
* 进行中时的进度描述(独立事务提交,供前端轮询)
*/
@Column(name = "progress_message", length = 512)
private String progressMessage;
/**
* 汉尼接口响应内容 * 汉尼接口响应内容
*/ */
@Column(name = "hanni_response", columnDefinition = "TEXT") @Column(name = "hanni_response", columnDefinition = "TEXT")
...@@ -120,6 +126,14 @@ public class ItemSyncLog { ...@@ -120,6 +126,14 @@ public class ItemSyncLog {
this.status = status; this.status = status;
} }
public String getProgressMessage() {
return progressMessage;
}
public void setProgressMessage(String progressMessage) {
this.progressMessage = progressMessage;
}
public String getHanniResponse() { public String getHanniResponse() {
return hanniResponse; return hanniResponse;
} }
......
...@@ -4,8 +4,9 @@ import cn.nhsoft.hanni.item.model.ItemSyncDetail; ...@@ -4,8 +4,9 @@ import cn.nhsoft.hanni.item.model.ItemSyncDetail;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface ItemSyncDetailRepository extends JpaRepository<ItemSyncDetail, Long> { public interface ItemSyncDetailRepository extends JpaRepository<ItemSyncDetail, Long>, JpaSpecificationExecutor<ItemSyncDetail> {
Page<ItemSyncDetail> findBySyncLogIdOrderByIdAsc(Long syncLogId, Pageable pageable); Page<ItemSyncDetail> findBySyncLogIdOrderByIdAsc(Long syncLogId, Pageable pageable);
} }
package cn.nhsoft.hanni.item.repository;
import cn.nhsoft.hanni.item.model.ItemSyncDetail;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;
import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
/**
* 同步明细分页查询条件(条码模糊、动作精确、成功状态)
*/
public final class ItemSyncDetailSpecification {
private ItemSyncDetailSpecification() {
}
/**
* @param successFilter null 不筛选;true 仅成功;false 非成功(含 false 与 null)
*/
public static Specification<ItemSyncDetail> forSyncLog(
Long syncLogId,
String barcode,
String action,
Boolean successFilter) {
return (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("syncLogId"), syncLogId));
if (StringUtils.hasText(barcode)) {
String pattern = "%" + barcode.trim().toLowerCase() + "%";
predicates.add(cb.like(cb.lower(root.get("barcode")), pattern));
}
if (StringUtils.hasText(action)) {
predicates.add(cb.equal(root.get("action"), action.trim()));
}
if (successFilter != null) {
if (Boolean.TRUE.equals(successFilter)) {
predicates.add(cb.isTrue(root.get("success")));
} else {
predicates.add(cb.or(
cb.isFalse(root.get("success")),
cb.isNull(root.get("success"))
));
}
}
return cb.and(predicates.toArray(new Predicate[0]));
};
}
}
package cn.nhsoft.hanni.item.service;
import cn.nhsoft.hanni.item.model.ItemSyncLog;
import cn.nhsoft.hanni.item.repository.ItemSyncLogRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
/**
* 同步任务进度独立提交(REQUIRES_NEW),便于列表页轮询看到实时进度。
*/
@Service
public class ItemSyncProgressService {
@Resource
private ItemSyncLogRepository itemSyncLogRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateProgress(Long logId, String message, Integer lemengCount) {
ItemSyncLog log = itemSyncLogRepository.findById(logId).orElse(null);
if (log == null) {
return;
}
log.setProgressMessage(message);
if (lemengCount != null) {
log.setLemengCount(lemengCount);
}
itemSyncLogRepository.saveAndFlush(log);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void markFailed(Long logId, String errorMsg) {
ItemSyncLog log = itemSyncLogRepository.findById(logId).orElse(null);
if (log == null) {
return;
}
log.setStatus("FAIL");
log.setErrorMsg(errorMsg);
itemSyncLogRepository.saveAndFlush(log);
}
}
...@@ -5,40 +5,26 @@ import cn.nhsoft.hanni.item.dto.LemonItemFindRequest; ...@@ -5,40 +5,26 @@ import cn.nhsoft.hanni.item.dto.LemonItemFindRequest;
/** /**
* 商品档案同步服务接口 * 商品档案同步服务接口
* <p>
* 负责从乐檬获取商品档案,加工后传入汉尼系统;
* 当养馋记系统字段变更时,调用汉尼系统更新接口
* </p>
*
* @author hanni-external-api
*/ */
public interface ItemSyncService { public interface ItemSyncService {
/** /**
* 同步商品档案 * 定时/内部:异步启动同步(不返回日志 ID)
* <p>从乐檬获取商品档案,加工后传入汉尼系统</p>
*/ */
void syncItems(); void syncItems();
/** /**
* 从乐檬获取商品档案 * 触发同步并返回日志 ID(立即返回,任务在后台执行)
* <p>仅调用乐檬接口,不做加工和同步;请求方式 GET,参数 page_no、page_size 必填</p>
*
* @param request 查询参数(对应 nhsoft.amazon.basic.item.find 接口文档)
* @return 乐檬商品档案原始响应
*/ */
String getItemsFromLemon(LemonItemFindRequest request); Long syncItemsStart();
/** /**
* 按乐檬查询参数手动同步到汉尼 * 按乐檬参数异步启动同步并返回日志 ID
* <p>使用指定查询参数从乐檬获取商品,经转换、过滤、变更检测后同步到汉尼</p>
*
* @param request 乐檬商品档案查询参数(与 nhsoft.amazon.basic.item.find 一致)
*/ */
void syncItemsWithParams(LemonItemFindRequest request); Long syncItemsWithParamsStart(LemonItemFindRequest request);
/** String getItemsFromLemon(LemonItemFindRequest request);
* 分页查询某次同步任务的商品明细
*/ ItemSyncDetailPageDTO getSyncDetails(Long syncLogId, int page, int size,
ItemSyncDetailPageDTO getSyncDetails(Long syncLogId, int page, int size); String barcode, String action, Boolean success);
} }
...@@ -2,6 +2,7 @@ package cn.nhsoft.hanni.item.service.impl; ...@@ -2,6 +2,7 @@ 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.config.AsyncConfig;
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.ItemSyncDetailPageDTO;
...@@ -11,16 +12,20 @@ import cn.nhsoft.hanni.item.model.ItemSyncDetail; ...@@ -11,16 +12,20 @@ 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.model.ItemSyncSnapshot; import cn.nhsoft.hanni.item.model.ItemSyncSnapshot;
import cn.nhsoft.hanni.item.repository.ItemSyncDetailRepository; import cn.nhsoft.hanni.item.repository.ItemSyncDetailRepository;
import cn.nhsoft.hanni.item.repository.ItemSyncDetailSpecification;
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.repository.ItemSyncSnapshotRepository;
import cn.nhsoft.hanni.item.service.ItemSyncProgressService;
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.beans.factory.annotation.Qualifier;
import org.springframework.data.domain.Page; 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.data.jpa.domain.Specification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.PlatformTransactionManager;
...@@ -35,9 +40,10 @@ import java.util.HashMap; ...@@ -35,9 +40,10 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.Executor;
/** /**
* 商品档案同步服务实现类 * 商品档案同步服务实现类(异步执行 + 进度独立事务提交)
*/ */
@Slf4j @Slf4j
@Service @Service
...@@ -67,6 +73,13 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -67,6 +73,13 @@ public class ItemSyncServiceImpl implements ItemSyncService {
@Resource @Resource
private PlatformTransactionManager platformTransactionManager; private PlatformTransactionManager platformTransactionManager;
@Resource
private ItemSyncProgressService itemSyncProgressService;
@Resource
@Qualifier(AsyncConfig.ITEM_SYNC_EXECUTOR)
private Executor itemSyncExecutor;
private TransactionTemplate transactionTemplate; private TransactionTemplate transactionTemplate;
@PostConstruct @PostConstruct
...@@ -74,13 +87,22 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -74,13 +87,22 @@ public class ItemSyncServiceImpl implements ItemSyncService {
this.transactionTemplate = new TransactionTemplate(platformTransactionManager); this.transactionTemplate = new TransactionTemplate(platformTransactionManager);
} }
@FunctionalInterface @Override
private interface SyncLogWork { public void syncItems() {
void execute(ItemSyncLog syncLog) throws Exception; LemonItemFindRequest findRequest = buildScheduledFindRequest();
runSyncAsync(findRequest, "SCHEDULED");
} }
/**
* 前端「立即同步」等 HTTP 触发:与定时任务共用增量参数,但类型记为 MANUAL(用户主动触发)。
* 仅 {@link #syncItems()} 定时任务记为 SCHEDULED。
*/
@Override @Override
public void syncItems() { public Long syncItemsStart() {
return runSyncAsync(buildScheduledFindRequest(), "MANUAL");
}
private LemonItemFindRequest buildScheduledFindRequest() {
LemonItemFindRequest findRequest = new LemonItemFindRequest(); LemonItemFindRequest findRequest = new LemonItemFindRequest();
findRequest.setPageNo(1); findRequest.setPageNo(1);
findRequest.setPageSize(100); findRequest.setPageSize(100);
...@@ -93,11 +115,11 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -93,11 +115,11 @@ public class ItemSyncServiceImpl implements ItemSyncService {
} else { } else {
log.info("首次全量同步,无 lastDownloadTime"); log.info("首次全量同步,无 lastDownloadTime");
} }
doSyncIncremental(findRequest, "SCHEDULED"); return findRequest;
} }
@Override @Override
public void syncItemsWithParams(LemonItemFindRequest request) { public Long syncItemsWithParamsStart(LemonItemFindRequest request) {
if (request == null) { if (request == null) {
request = new LemonItemFindRequest(); request = new LemonItemFindRequest();
} }
...@@ -107,53 +129,99 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -107,53 +129,99 @@ public class ItemSyncServiceImpl implements ItemSyncService {
if (request.getPageSize() == null) { if (request.getPageSize() == null) {
request.setPageSize(100); request.setPageSize(100);
} }
doSyncIncremental(request, "MANUAL"); LemonItemFindRequest copy = new LemonItemFindRequest();
} BeanUtils.copyProperties(request, copy);
return runSyncAsync(copy, "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) { /**
// 未显式设置时与定时任务一致:向乐檬传 filter_sleep/filter_weed_out(仅依赖接口查询参数) * 异步执行:先落库 RUNNING 并 flush,再在线程池中跑同步;立即返回日志 ID。
*/
private Long runSyncAsync(LemonItemFindRequest request, String syncType) {
if (request.getFilterSleep() == null) { if (request.getFilterSleep() == null) {
request.setFilterSleep(Boolean.TRUE); request.setFilterSleep(Boolean.TRUE);
} }
if (request.getFilterWeedOut() == null) { if (request.getFilterWeedOut() == null) {
request.setFilterWeedOut(Boolean.TRUE); request.setFilterWeedOut(Boolean.TRUE);
} }
runWithSyncLog(syncType, syncLog -> { ItemSyncLog syncLog = new ItemSyncLog();
List<LemonItemRow> allRows = new ArrayList<>(); syncLog.setSyncType(syncType);
int pageNo = request.getPageNo() != null ? request.getPageNo() : 1; syncLog.setStartTime(new Date());
int pageSize = request.getPageSize() != null ? request.getPageSize() : 100; syncLog.setStatus("RUNNING");
int lemengTotal = 0; syncLog.setProgressMessage("任务已启动,准备拉取乐檬...");
syncLog = itemSyncLogRepository.saveAndFlush(syncLog);
while (true) { Long logId = syncLog.getId();
LemonItemFindRequest pageReq = copyPageRequest(request, pageNo, pageSize);
log.info("从乐檬获取商品档案,pageNo={}, lastDownloadTime={}", pageNo, request.getLastDownloadTime()); LemonItemFindRequest asyncReq = new LemonItemFindRequest();
String lemonItems = lemonApiClient.findItems(pageReq); BeanUtils.copyProperties(request, asyncReq);
List<LemonItemRow> pageRows = lemonToHanniItemConverter.convertToRows(lemonItems);
int count = parseLemengItemCount(lemonItems); itemSyncExecutor.execute(() -> executeSyncJob(logId, asyncReq));
lemengTotal += count; return logId;
}
if (pageRows == null || pageRows.isEmpty()) {
break; private void executeSyncJob(Long logId, LemonItemFindRequest request) {
} long startMs = System.currentTimeMillis();
allRows.addAll(pageRows); try {
if (count < pageSize) { doSyncIncrementalBody(logId, request);
break; } catch (Exception e) {
log.error("商品同步失败 logId={}", logId, e);
itemSyncProgressService.markFailed(logId, e.getMessage() != null ? e.getMessage() : e.toString());
} finally {
ItemSyncLog sl = itemSyncLogRepository.findById(logId).orElse(null);
if (sl != null) {
sl.setEndTime(new Date());
sl.setDurationMs(System.currentTimeMillis() - startMs);
if ("RUNNING".equals(sl.getStatus())) {
sl.setStatus("FAIL");
if (sl.getErrorMsg() == null || sl.getErrorMsg().isEmpty()) {
sl.setErrorMsg("同步未完成");
}
sl.setProgressMessage(null);
} }
pageNo++; itemSyncLogRepository.saveAndFlush(sl);
} }
}
}
private void doSyncIncrementalBody(Long logId, LemonItemFindRequest request) {
itemSyncProgressService.updateProgress(logId, "正在拉取乐檬商品(分页)...", null);
syncLog.setLemengCount(lemengTotal); List<LemonItemRow> allRows = new ArrayList<>();
transactionTemplate.execute(status -> { int pageNo = request.getPageNo() != null ? request.getPageNo() : 1;
processAndSyncToHanniFromList(allRows, syncLog); int pageSize = request.getPageSize() != null ? request.getPageSize() : 100;
return null; int lemengTotal = 0;
});
while (true) {
LemonItemFindRequest pageReq = copyPageRequest(request, pageNo, pageSize);
log.info("从乐檬获取商品档案,pageNo={}, lastDownloadTime={}", pageNo, request.getLastDownloadTime());
String lemonItems = lemonApiClient.findItems(pageReq);
List<LemonItemRow> pageRows = lemonToHanniItemConverter.convertToRows(lemonItems);
int count = parseLemengItemCount(lemonItems);
lemengTotal += count;
if (pageRows == null || pageRows.isEmpty()) {
break;
}
allRows.addAll(pageRows);
itemSyncProgressService.updateProgress(logId,
String.format("已拉取第 %d 页,本页约 %d 条,累计转换 %d 条", pageNo, count, allRows.size()),
lemengTotal);
if (count < pageSize) {
break;
}
pageNo++;
}
itemSyncProgressService.updateProgress(logId,
String.format("拉取完成,乐檬返回约 %d 条,有效转换 %d 条;开始处理并推送汉尼", lemengTotal, allRows.size()),
lemengTotal);
final int lemengTotalFinal = lemengTotal;
transactionTemplate.execute(status -> {
ItemSyncLog managed = itemSyncLogRepository.findById(logId)
.orElseThrow(() -> new IllegalStateException("item_sync_log 不存在: " + logId));
processAndSyncToHanniFromList(allRows, managed, lemengTotalFinal);
return null;
}); });
} }
...@@ -165,38 +233,24 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -165,38 +233,24 @@ public class ItemSyncServiceImpl implements ItemSyncService {
return p; return p;
} }
private void runWithSyncLog(String syncType, SyncLogWork work) { @Override
ItemSyncLog syncLog = new ItemSyncLog(); public ItemSyncDetailPageDTO getSyncDetails(Long syncLogId, int page, int size,
syncLog.setSyncType(syncType); String barcode, String action, Boolean success) {
syncLog.setStartTime(new Date()); PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.ASC, "id"));
syncLog.setStatus("RUNNING"); Specification<ItemSyncDetail> spec = ItemSyncDetailSpecification.forSyncLog(syncLogId, barcode, action, success);
syncLog = itemSyncLogRepository.saveAndFlush(syncLog); Page<ItemSyncDetail> p = itemSyncDetailRepository.findAll(spec, pageable);
long startMs = System.currentTimeMillis(); return new ItemSyncDetailPageDTO(p.getContent(), p.getTotalElements());
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);
if ("RUNNING".equals(syncLog.getStatus())) {
syncLog.setStatus("FAIL");
if (syncLog.getErrorMsg() == null || syncLog.getErrorMsg().isEmpty()) {
syncLog.setErrorMsg("同步未完成");
}
}
itemSyncLogRepository.save(syncLog);
}
} }
private void processAndSyncToHanniFromList(List<LemonItemRow> rows, ItemSyncLog syncLog) { private void processAndSyncToHanniFromList(List<LemonItemRow> rows, ItemSyncLog syncLog, int lemengTotalCount) {
Long logId = syncLog.getId(); Long logId = syncLog.getId();
syncLog.setLemengCount(lemengTotalCount);
if (rows == null || rows.isEmpty()) { if (rows == null || rows.isEmpty()) {
syncLog.setHanniCount(0); syncLog.setHanniCount(0);
syncLog.setStatus("SKIP"); syncLog.setStatus("SKIP");
syncLog.setErrorMsg("转换后无有效商品数据"); syncLog.setErrorMsg("转换后无有效商品数据");
itemSyncLogRepository.save(syncLog);
return; return;
} }
...@@ -224,6 +278,8 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -224,6 +278,8 @@ public class ItemSyncServiceImpl implements ItemSyncService {
d.setGid(item.getGid()); d.setGid(item.getGid());
d.setName(item.getName()); d.setName(item.getName());
d.setAction(ItemSyncDetail.ACTION_SKIP_NO_BARCODE); d.setAction(ItemSyncDetail.ACTION_SKIP_NO_BARCODE);
d.setLemonLastModified(row.getLemonLastModified());
d.setStoredLemonModified(null);
d.setSuccess(true); d.setSuccess(true);
d.setRemark("缺少条码,不同步"); d.setRemark("缺少条码,不同步");
details.add(d); details.add(d);
...@@ -264,6 +320,7 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -264,6 +320,7 @@ public class ItemSyncServiceImpl implements ItemSyncService {
updateDetailByBc.put(barcode, d); updateDetailByBc.put(barcode, d);
} else { } else {
d.setAction(ItemSyncDetail.ACTION_ADD); d.setAction(ItemSyncDetail.ACTION_ADD);
d.setStoredLemonModified(null);
toAdd.add(item); toAdd.add(item);
addRows.add(row); addRows.add(row);
addDetailByBc.put(barcode, d); addDetailByBc.put(barcode, d);
...@@ -271,9 +328,15 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -271,9 +328,15 @@ public class ItemSyncServiceImpl implements ItemSyncService {
details.add(d); details.add(d);
} }
itemSyncProgressService.updateProgress(logId,
String.format("分类完成:待推送汉尼新增 %d 条、更新 %d 条", toAdd.size(), toUpdate.size()),
null);
StringBuilder responseBuilder = new StringBuilder(); StringBuilder responseBuilder = new StringBuilder();
if (!toAdd.isEmpty()) { if (!toAdd.isEmpty()) {
itemSyncProgressService.updateProgress(logId,
String.format("正在调用汉尼新增接口…(%d 条)", toAdd.size()), null);
log.info("新增 {} 条商品到汉尼", toAdd.size()); log.info("新增 {} 条商品到汉尼", toAdd.size());
String r = hanniApiClient.saveItems(toAdd); String r = hanniApiClient.saveItems(toAdd);
if (r != null) { if (r != null) {
...@@ -283,14 +346,19 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -283,14 +346,19 @@ public class ItemSyncServiceImpl implements ItemSyncService {
for (LemonItemRow rr : addRows) { for (LemonItemRow rr : addRows) {
ItemSyncDetail d = addDetailByBc.get(rr.getItem().getBarcode()); ItemSyncDetail d = addDetailByBc.get(rr.getItem().getBarcode());
if (d != null) { if (d != null) {
d.setLemonLastModified(rr.getLemonLastModified());
d.setStoredLemonModified(null);
d.setSuccess(true); d.setSuccess(true);
d.setRemark(shortR); d.setRemark(shortR);
} }
upsertSnapshot(rr); upsertSnapshot(rr);
} }
itemSyncProgressService.updateProgress(logId, "汉尼新增接口已完成,正在处理快照", null);
} }
if (!toUpdate.isEmpty()) { if (!toUpdate.isEmpty()) {
itemSyncProgressService.updateProgress(logId,
String.format("正在调用汉尼更新接口…(%d 条)", toUpdate.size()), null);
log.info("更新 {} 条变更商品到汉尼", toUpdate.size()); log.info("更新 {} 条变更商品到汉尼", toUpdate.size());
String r = hanniApiClient.updateItems(toUpdate); String r = hanniApiClient.updateItems(toUpdate);
if (r != null) { if (r != null) {
...@@ -298,19 +366,32 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -298,19 +366,32 @@ public class ItemSyncServiceImpl implements ItemSyncService {
} }
String shortR = truncateStr(r, 500); String shortR = truncateStr(r, 500);
for (LemonItemRow rr : updateRows) { for (LemonItemRow rr : updateRows) {
ItemSyncDetail d = updateDetailByBc.get(rr.getItem().getBarcode()); String bc = rr.getItem().getBarcode();
ItemSyncDetail d = updateDetailByBc.get(bc);
if (d != null) { if (d != null) {
d.setLemonLastModified(rr.getLemonLastModified());
itemSyncSnapshotRepository.findByBarcode(bc).ifPresent(snap ->
d.setStoredLemonModified(snap.getLemonLastModified()));
d.setSuccess(true); d.setSuccess(true);
d.setRemark(shortR); d.setRemark(shortR);
} }
upsertSnapshot(rr); upsertSnapshot(rr);
} }
itemSyncProgressService.updateProgress(logId, "汉尼更新接口已完成,正在处理快照", null);
} }
saveDetailsInChunks(details); if (toAdd.isEmpty() && toUpdate.isEmpty()) {
itemSyncProgressService.updateProgress(logId, "无需推送汉尼(均为跳过),正在写入明细", null);
}
itemSyncProgressService.updateProgress(logId,
String.format("正在写入同步明细(共 %d 条)…", details.size()), null);
saveDetailsInChunks(details, logId);
syncLog.setStatus("SUCCESS"); syncLog.setStatus("SUCCESS");
syncLog.setHanniResponse(truncateStr(responseBuilder.toString(), 2000)); syncLog.setHanniResponse(truncateStr(responseBuilder.toString(), 2000));
syncLog.setProgressMessage("已完成");
itemSyncLogRepository.save(syncLog);
} }
private static String normalizeMod(String s) { private static String normalizeMod(String s) {
...@@ -337,11 +418,16 @@ public class ItemSyncServiceImpl implements ItemSyncService { ...@@ -337,11 +418,16 @@ public class ItemSyncServiceImpl implements ItemSyncService {
itemSyncSnapshotRepository.save(s); itemSyncSnapshotRepository.save(s);
} }
private void saveDetailsInChunks(List<ItemSyncDetail> details) { private void saveDetailsInChunks(List<ItemSyncDetail> details, Long logId) {
int batch = 200; int batch = 200;
for (int i = 0; i < details.size(); i += batch) { int total = details.size();
int end = Math.min(i + batch, details.size()); for (int i = 0; i < total; i += batch) {
int end = Math.min(i + batch, total);
itemSyncDetailRepository.saveAll(details.subList(i, end)); itemSyncDetailRepository.saveAll(details.subList(i, end));
if (logId != null && total > batch) {
itemSyncProgressService.updateProgress(logId,
String.format("正在写入同步明细 %d/%d …", end, total), null);
}
} }
} }
......
...@@ -3,14 +3,17 @@ package cn.nhsoft.hanni.lemeng.controller; ...@@ -3,14 +3,17 @@ package cn.nhsoft.hanni.lemeng.controller;
import cn.nhsoft.hanni.common.Result; 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.LemengTokenStatusDTO;
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.service.LemengTokenPersistService;
import cn.nhsoft.hanni.lemeng.service.LemengTokenStatusService;
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;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
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;
...@@ -39,6 +42,15 @@ public class LemengTokenController { ...@@ -39,6 +42,15 @@ public class LemengTokenController {
@Resource @Resource
private LemengProperties lemengProperties; private LemengProperties lemengProperties;
@Resource
private LemengTokenStatusService lemengTokenStatusService;
@Operation(summary = "Token 状态", description = "当前 access_token 来源、JWT 是否过期、是否存在 refresh_token(不返回密钥内容)")
@GetMapping("/status")
public Result<LemengTokenStatusDTO> status() {
return Result.success(lemengTokenStatusService.getStatus());
}
@Operation(summary = "刷新 Token", description = "POST /oauth/token,grant_type=refresh_token") @Operation(summary = "刷新 Token", description = "POST /oauth/token,grant_type=refresh_token")
@PostMapping("/refresh") @PostMapping("/refresh")
public Result<TokenRefreshResult> refreshToken( public Result<TokenRefreshResult> refreshToken(
......
package cn.nhsoft.hanni.lemeng.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 乐檬 Token 状态(校验展示用)
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LemengTokenStatusDTO {
/** 是否有可用的 access_token(内存或配置) */
private boolean hasAccessToken;
/** 来源:memory / config / none */
private String source;
/** 当前默认账套号(若有) */
private String systemBookCode;
/** JWT exp(Unix 秒),无法解析时为 null */
private Long accessTokenExpiresAt;
/** 按 JWT exp 判断是否仍有效 */
private boolean accessTokenValid;
/** 内存或库中是否存在 refresh_token */
private boolean refreshTokenPresent;
/** 说明 */
private String message;
}
...@@ -7,4 +7,6 @@ import org.springframework.data.jpa.repository.JpaRepository; ...@@ -7,4 +7,6 @@ import org.springframework.data.jpa.repository.JpaRepository;
* 乐檬 OAuth Token 表 * 乐檬 OAuth Token 表
*/ */
public interface LemengOAuthTokenRepository extends JpaRepository<LemengOAuthToken, String> { public interface LemengOAuthTokenRepository extends JpaRepository<LemengOAuthToken, String> {
boolean existsByRefreshTokenIsNotNull();
} }
...@@ -70,7 +70,8 @@ public class LemengTokenPersistService implements ApplicationRunner { ...@@ -70,7 +70,8 @@ public class LemengTokenPersistService implements ApplicationRunner {
if (expiresIn != null && expiresIn > 0) { if (expiresIn != null && expiresIn > 0) {
entity.setExpiresAt(new Date(System.currentTimeMillis() + expiresIn * 1000L)); entity.setExpiresAt(new Date(System.currentTimeMillis() + expiresIn * 1000L));
} }
lemengOAuthTokenRepository.save(entity); lemengOAuthTokenRepository.saveAndFlush(entity);
log.info("乐檬 Token 已写入数据库表 lemeng_oauth_token,账套: {}", systemBookCode);
tokenHolder.setToken(systemBookCode, accessToken, refreshToken); tokenHolder.setToken(systemBookCode, accessToken, refreshToken);
} }
......
package cn.nhsoft.hanni.lemeng.service;
import cn.nhsoft.hanni.config.LemonApiProperties;
import cn.nhsoft.hanni.lemeng.dto.LemengTokenStatusDTO;
import cn.nhsoft.hanni.lemeng.model.LemengOAuthToken;
import cn.nhsoft.hanni.lemeng.repository.LemengOAuthTokenRepository;
import cn.nhsoft.hanni.lemeng.util.JwtExpUtil;
import cn.nhsoft.hanni.lemeng.util.TokenHolder;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.Optional;
/**
* 乐檬 Token 状态(用于前端校验展示)
*/
@Service
public class LemengTokenStatusService {
@Resource
private TokenHolder tokenHolder;
@Resource
private LemonApiProperties lemonApiProperties;
@Resource
private LemengOAuthTokenRepository lemengOAuthTokenRepository;
@Resource
private ObjectMapper objectMapper;
public LemengTokenStatusDTO getStatus() {
String token = tokenHolder.getAccessToken();
String source = "memory";
if (!StringUtils.hasText(token)) {
token = lemonApiProperties.getAccessToken();
source = "config";
}
if (!StringUtils.hasText(token)) {
return LemengTokenStatusDTO.builder()
.hasAccessToken(false)
.source("none")
.accessTokenValid(false)
.refreshTokenPresent(hasRefreshInDbOrMemory(null))
.message("无 access_token:请完成 OAuth、POST /lemeng/token/set,或配置 lemon.api.access-token")
.build();
}
Long expSec = JwtExpUtil.extractExpSeconds(token, objectMapper);
long nowMs = System.currentTimeMillis();
boolean valid;
String msg;
if (expSec == null) {
valid = true;
msg = "JWT 无 exp 或无法解析,假定可用;建议以实际调用乐檬接口结果为准";
} else {
valid = expSec * 1000L > nowMs + 10_000L;
msg = valid ? "access_token 未过期(按 JWT exp)" : "access_token 已过期(按 JWT exp),请刷新或重新授权";
}
String book = tokenHolder.getDefaultSystemBookCode();
if (!StringUtils.hasText(book)) {
book = null;
}
boolean refreshPresent = hasRefreshInDbOrMemory(book);
return LemengTokenStatusDTO.builder()
.hasAccessToken(true)
.source(source)
.systemBookCode(book)
.accessTokenExpiresAt(expSec)
.accessTokenValid(valid)
.refreshTokenPresent(refreshPresent)
.message(msg)
.build();
}
private boolean hasRefreshInDbOrMemory(String systemBookCode) {
String rt = tokenHolder.getRefreshToken();
if (StringUtils.hasText(rt)) {
return true;
}
if (StringUtils.hasText(systemBookCode)) {
Optional<LemengOAuthToken> o = lemengOAuthTokenRepository.findById(systemBookCode);
return o.isPresent() && StringUtils.hasText(o.get().getRefreshToken());
}
return lemengOAuthTokenRepository.existsByRefreshTokenIsNotNull();
}
}
package cn.nhsoft.hanni.lemeng.util;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* 解析 JWT payload 中的 exp(秒)
*/
public final class JwtExpUtil {
private JwtExpUtil() {
}
public static Long extractExpSeconds(String jwt, ObjectMapper objectMapper) {
if (jwt == null || !jwt.contains(".")) {
return null;
}
try {
String[] parts = jwt.split("\\.");
if (parts.length < 2) {
return null;
}
byte[] decoded = Base64.getUrlDecoder().decode(parts[1]);
JsonNode node = objectMapper.readTree(new String(decoded, StandardCharsets.UTF_8));
JsonNode exp = node.get("exp");
if (exp == null || exp.isNull()) {
return null;
}
return exp.isNumber() ? exp.asLong() : Long.parseLong(exp.asText());
} catch (Exception e) {
return null;
}
}
}
...@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS `item_sync_log` ( ...@@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS `item_sync_log` (
`lemeng_count` INT DEFAULT NULL COMMENT '乐檬返回商品条数', `lemeng_count` INT DEFAULT NULL COMMENT '乐檬返回商品条数',
`hanni_count` INT DEFAULT NULL COMMENT '本批处理条数', `hanni_count` INT DEFAULT NULL COMMENT '本批处理条数',
`status` VARCHAR(16) DEFAULT NULL COMMENT 'SUCCESS/FAIL/SKIP/RUNNING', `status` VARCHAR(16) DEFAULT NULL COMMENT 'SUCCESS/FAIL/SKIP/RUNNING',
`progress_message` VARCHAR(512) DEFAULT NULL COMMENT '进行中时的进度描述(独立提交)',
`hanni_response` TEXT DEFAULT NULL COMMENT '汉尼接口响应摘要', `hanni_response` TEXT DEFAULT NULL COMMENT '汉尼接口响应摘要',
`error_msg` TEXT DEFAULT NULL COMMENT '失败原因', `error_msg` TEXT DEFAULT NULL COMMENT '失败原因',
`start_time` DATETIME(3) DEFAULT NULL COMMENT '开始时间', `start_time` DATETIME(3) DEFAULT NULL COMMENT '开始时间',
......
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