Commit f180b5d0 by yangxianglong

自采联营门店系统开发

parent 25c521e9
......@@ -14,8 +14,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
*/
@EnableScheduling
@SpringBootApplication
@EntityScan({"cn.nhsoft.hanni.item"})
@EnableJpaRepositories({"cn.nhsoft.hanni.item"})
@EntityScan({"cn.nhsoft.hanni.item", "cn.nhsoft.hanni.lemeng"})
@EnableJpaRepositories({"cn.nhsoft.hanni.item", "cn.nhsoft.hanni.lemeng.repository"})
public class HanniExternalApiApplication {
public static void main(String[] args) {
......
package cn.nhsoft.hanni.item.controller;
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.model.ItemSyncLog;
import cn.nhsoft.hanni.item.repository.ItemSyncLogRepository;
......@@ -13,6 +14,7 @@ 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.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
......@@ -92,4 +94,13 @@ public class ItemController {
ItemSyncLogPageDTO dto = new ItemSyncLogPageDTO(resultPage.getContent(), resultPage.getTotalElements());
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;
import cn.nhsoft.hanni.item.dto.HanniItemDTO;
import cn.nhsoft.hanni.item.dto.LemonItemRow;
import cn.nhsoft.hanni.item.util.LemonItemResponseParser;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
......@@ -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) {
try {
Map<String, Object> map = objectMapper.convertValue(node, new TypeReference<Map<String, Object>>() {});
......@@ -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;
import cn.nhsoft.hanni.item.dto.ItemSyncDetailPageDTO;
import cn.nhsoft.hanni.item.dto.LemonItemFindRequest;
import java.util.List;
/**
* 商品档案同步服务接口
* <p>
......@@ -37,4 +36,9 @@ public interface ItemSyncService {
* @param request 乐檬商品档案查询参数(与 nhsoft.amazon.basic.item.find 一致)
*/
void syncItemsWithParams(LemonItemFindRequest request);
/**
* 分页查询某次同步任务的商品明细
*/
ItemSyncDetailPageDTO getSyncDetails(Long syncLogId, int page, int size);
}
......@@ -3,7 +3,7 @@ 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 cn.nhsoft.hanni.lemeng.service.LemengTokenPersistService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
......@@ -32,7 +32,7 @@ public class LemengAuthController {
private LemengProperties lemengProperties;
@Resource
private TokenHolder tokenHolder;
private LemengTokenPersistService lemengTokenPersistService;
@Operation(summary = "OAuth2 授权回调", description = "使用 code 换取 access_token 并写入 TokenHolder;无 code 时跳转 Swagger")
@GetMapping("/code")
......@@ -61,7 +61,7 @@ public class LemengAuthController {
log.error("无法解析账套号,请配置 lemeng.app.system-book-code");
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);
} catch (Exception e) {
log.error("乐檬 OAuth 获取 Token 失败", e);
......
......@@ -4,7 +4,7 @@ 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 cn.nhsoft.hanni.lemeng.service.LemengTokenPersistService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
......@@ -34,7 +34,7 @@ public class LemengTokenController {
private LemengTokenRestClient lemengTokenRestClient;
@Resource
private TokenHolder tokenHolder;
private LemengTokenPersistService lemengTokenPersistService;
@Resource
private LemengProperties lemengProperties;
......@@ -59,7 +59,7 @@ public class LemengTokenController {
if (!StringUtils.hasText(book)) {
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);
} catch (HttpClientErrorException e) {
HttpStatus status = e.getStatusCode();
......@@ -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")
public Result<String> setToken(
@Parameter(description = "账套号", required = true) @RequestParam String systemBookCode,
......@@ -94,7 +94,7 @@ public class LemengTokenController {
if (!StringUtils.hasText(systemBookCode) || !StringUtils.hasText(accessToken) || !StringUtils.hasText(refreshToken)) {
return Result.error("systemBookCode、accessToken、refreshToken 均不能为空");
}
tokenHolder.setToken(systemBookCode, accessToken, refreshToken);
lemengTokenPersistService.saveAndCache(systemBookCode, accessToken, refreshToken);
log.info("手动设置 Token 成功,账套号: {}", systemBookCode);
return Result.success(systemBookCode);
}
......
......@@ -3,6 +3,7 @@ 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.service.LemengTokenPersistService;
import cn.nhsoft.hanni.lemeng.util.TokenHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
......@@ -28,6 +29,9 @@ public class LemengTokenRefreshJob {
private TokenHolder tokenHolder;
@Resource
private LemengTokenPersistService lemengTokenPersistService;
@Resource
private LemengProperties lemengProperties;
@Scheduled(fixedDelay = 50 * 60 * 1000, initialDelay = 60 * 1000)
......@@ -59,7 +63,7 @@ public class LemengTokenRefreshJob {
if (!StringUtils.hasText(book)) {
book = systemBookCode;
}
tokenHolder.setToken(book, result.getAccessToken(), result.getRefreshToken());
lemengTokenPersistService.saveAndCache(book, result.getAccessToken(), result.getRefreshToken(), result.getExpiresIn());
log.info("定时刷新 token 成功,账套: {}", book);
} catch (Exception 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;
import java.util.concurrent.ConcurrentHashMap;
/**
* 内存 Token 存储,供 RestTemplate 方式商品查询及定时刷新使用
* 通过 /lemeng/token/set 设置后,LemonApiClient 从此读取
* 进程内内存 Token 缓存,供 RestTemplate 商品查询及定时刷新使用。
* <p>写入请通过 {@link cn.nhsoft.hanni.lemeng.service.LemengTokenPersistService},以保证与数据库同步;
* 服务启动时会从表 {@code lemeng_oauth_token} 加载到此。</p>
*/
@Component
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