package com.doumee.service.business.impl;
|
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.doumee.core.constants.Constants;
|
import com.doumee.core.constants.ResponseStatus;
|
import com.doumee.core.douyin.DouyinClient;
|
import com.doumee.core.douyin.dto.DouyinBaseResp;
|
import com.doumee.core.douyin.dto.DouyinOnlineQueryReq;
|
import com.doumee.core.douyin.dto.DouyinOnlineQueryResp;
|
import com.doumee.core.douyin.dto.DouyinProductDTO;
|
import com.doumee.core.douyin.dto.DouyinSkuDTO;
|
import com.doumee.core.exception.BusinessException;
|
import com.doumee.core.model.LoginUserInfo;
|
import com.doumee.core.model.PageData;
|
import com.doumee.core.model.PageWrap;
|
import com.doumee.core.utils.ID;
|
import com.doumee.dao.business.DiscountMapper;
|
import com.doumee.dao.business.DouyinProductMapper;
|
import com.doumee.dao.business.DouyinProductSkuMapper;
|
import com.doumee.dao.business.model.Discount;
|
import com.doumee.dao.business.model.DouyinProduct;
|
import com.doumee.dao.business.model.DouyinProductSku;
|
import com.doumee.service.business.DouyinProductService;
|
import com.github.yulichang.wrapper.MPJLambdaWrapper;
|
import lombok.extern.slf4j.Slf4j;
|
import org.apache.commons.lang3.StringUtils;
|
import org.apache.shiro.SecurityUtils;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
|
import java.util.Collections;
|
import java.util.Date;
|
import java.util.HashSet;
|
import java.util.List;
|
import java.util.Set;
|
|
/**
|
* 抖音商品 Service 实现
|
*
|
* @author rk
|
* @date 2026/06/22
|
*/
|
@Slf4j
|
@Service
|
public class DouyinProductServiceImpl implements DouyinProductService {
|
|
@Autowired
|
private DouyinProductMapper douyinProductMapper;
|
@Autowired
|
private DouyinProductSkuMapper douyinProductSkuMapper;
|
@Autowired
|
private DiscountMapper discountMapper;
|
@Autowired
|
private DouyinClient douyinClient;
|
|
/** 每页拉取条数 */
|
private static final int PAGE_SIZE = 50;
|
|
/**
|
* 从抖音全量同步商品:游标翻页拉取 online/query,逐条 upsert 本地商品 + SKU。
|
* 整体事务包裹,任一批次失败则全部回滚。
|
*
|
* @return 本次同步的商品条数
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int syncFromDouyin() {
|
// 取操作人:platform 端走 Shiro 登录态;web 端(如 testQuery 联调)无 Shiro 环境会抛异常,此时留空,不影响入库
|
String operator = null;
|
try {
|
LoginUserInfo user = (LoginUserInfo) SecurityUtils.getSubject().getPrincipal();
|
operator = user == null ? null : user.getId();
|
} catch (Exception e) {
|
// 非 Shiro 环境(web JWT 端调用),operator 留空,仅 creator/editor 落 null
|
}
|
int total = 0;
|
// 无 SKU 被跳过的商品数(冗余数据,不存储),仅用于日志统计
|
int skipped = 0;
|
// 本次抖音命中的商品ID集合,用于末尾对账:未命中的本地在售商品置为下架
|
Set<String> syncedProductIds = new HashSet<>();
|
String cursor = null;
|
while (true) {
|
DouyinOnlineQueryReq req = new DouyinOnlineQueryReq();
|
req.setCursor(cursor);
|
req.setCount(PAGE_SIZE);
|
DouyinBaseResp<DouyinOnlineQueryResp> resp = douyinClient.onlineQuery(req);
|
Integer errCode = resp == null || resp.getExtra() == null ? null : resp.getExtra().getErrorCode();
|
if (errCode == null || errCode != 0) {
|
String desc = resp == null || resp.getExtra() == null ? "无响应" : resp.getExtra().getDescription();
|
throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "抖音商品同步失败:" + desc);
|
}
|
DouyinOnlineQueryResp data = resp.getData();
|
List<DouyinProductDTO> products = data == null ? null : data.getProducts();
|
if (products != null && !products.isEmpty()) {
|
for (DouyinProductDTO dto : products) {
|
// 无 SKU 的商品不存储(冗余数据);upsertProduct 返回 false 即跳过
|
if (upsertProduct(dto, operator)) {
|
total++;
|
} else if (dto.getProduct() != null
|
&& StringUtils.isNotBlank(dto.getProduct().getProductId())) {
|
// product 有效但被跳过(无 SKU),仅用于日志统计
|
skipped++;
|
}
|
// 命中集合:product 有效即纳入(无论是否存储),保护本地已有的同 ID 商品不被对账下架
|
if (dto.getProduct() != null && StringUtils.isNotBlank(dto.getProduct().getProductId())) {
|
syncedProductIds.add(dto.getProduct().getProductId());
|
}
|
}
|
}
|
if (data == null || !Boolean.TRUE.equals(data.getHasMore())
|
|| StringUtils.isBlank(data.getNextCursor())) {
|
break;
|
}
|
cursor = data.getNextCursor();
|
}
|
|
// 对账:本次抖音未返回、本地仍标记在线的商品 → 置为下架(online_status=2)
|
// 仅下架「在线(1)」的,不动已下架(2)/封禁(3),避免把更严重的封禁状态降级
|
// 集合非空才执行:空集合说明抖音本次一条都没返回(接口异常/商户无在售),不应据此清空本地
|
if (!syncedProductIds.isEmpty()) {
|
int offlineCount = douyinProductMapper.update(null, new UpdateWrapper<DouyinProduct>().lambda()
|
.set(DouyinProduct::getOnlineStatus, Constants.TWO) // 2 下线
|
.set(DouyinProduct::getEditDate, new Date())
|
.set(DouyinProduct::getEditor, operator)
|
.eq(DouyinProduct::getIsdeleted, Constants.ZERO)
|
.eq(DouyinProduct::getOnlineStatus, Constants.ONE) // 仅当前在线的
|
.notIn(DouyinProduct::getProductId, syncedProductIds));
|
log.info("抖音商品同步对账:本地未命中 {} 条,已置为下架", offlineCount);
|
}
|
if (skipped > 0) {
|
log.info("抖音商品同步:跳过无SKU商品 {} 条(不存储)", skipped);
|
}
|
return total;
|
}
|
|
/**
|
* 按 抖音商品ID(product_id) upsert 主表;SKU 采用「逻辑删除旧的 + 插入新的」全量覆盖。
|
* <p>抖音 online/query 的商品基础信息藏在 products[].product 子对象里(见 {@link DouyinProductDTO}),
|
* 故商品字段一律从 dto.product 取;SKU 列表取自顶层 dto.skus。
|
* <p>无 SKU 的商品(冗余数据)不予存储:不新增主表,也不更新本地已有记录。
|
*
|
* @return true=已存储(新增或更新);false=跳过(无 SKU 或脏数据)
|
*/
|
private boolean upsertProduct(DouyinProductDTO dto, String operator) {
|
// product 子对象缺失或无商品ID,跳过(防脏数据落库)
|
if (dto == null || dto.getProduct() == null
|
|| StringUtils.isBlank(dto.getProduct().getProductId())) {
|
return false;
|
}
|
// 归集 SKU:多规格走 skus(复数数组);单 SKU 团购(product_type=1)走 sku(单数对象)。
|
// 两种形态统一归集后判空;无 SKU 的商品视为冗余数据,不予存储(不新增,已有记录保留不动)
|
List<DouyinSkuDTO> skuList = dto.getSkus();
|
if ((skuList == null || skuList.isEmpty()) && dto.getSku() != null) {
|
skuList = Collections.singletonList(dto.getSku());
|
}
|
if (skuList == null || skuList.isEmpty()) {
|
// 无 SKU:不新增主表,也不更新本地已有记录及其 SKU
|
return false;
|
}
|
DouyinProductDTO.DouyinProductInfoDTO info = dto.getProduct();
|
Date now = new Date();
|
DouyinProduct exist = douyinProductMapper.selectOne(new QueryWrapper<DouyinProduct>().lambda()
|
.eq(DouyinProduct::getProductId, info.getProductId())
|
.eq(DouyinProduct::getIsdeleted, Constants.ZERO)
|
.last("limit 1"));
|
DouyinProduct p = exist == null ? new DouyinProduct() : exist;
|
p.setProductId(info.getProductId());
|
// out_id 不再由抖音同步写入,改为管理端绑定本地套餐(discount.id),见 bindDiscount
|
p.setProductName(info.getProductName());
|
// 类目取 category_full_name(文本,展示友好),而非 category_id
|
p.setCategory(info.getCategoryFullName());
|
p.setProductType(info.getProductType());
|
p.setOnlineStatus(dto.getOnlineStatus() == null ? Constants.ONE : dto.getOnlineStatus());
|
// 账户ID 取归属账户 owner_account_id(数字转字符串落库)
|
p.setAccountId(info.getOwnerAccountId() == null ? null : String.valueOf(info.getOwnerAccountId()));
|
p.setSyncDate(now);
|
p.setIsdeleted(Constants.ZERO);
|
if (exist == null) {
|
p.setId(ID.nextGUID());
|
p.setCreateDate(now);
|
p.setCreator(operator);
|
douyinProductMapper.insert(p);
|
} else {
|
p.setEditDate(now);
|
p.setEditor(operator);
|
douyinProductMapper.updateById(p);
|
}
|
// SKU 先逻辑删除旧的,再插入新的(全量覆盖)
|
douyinProductSkuMapper.update(null, new UpdateWrapper<DouyinProductSku>().lambda()
|
.set(DouyinProductSku::getIsdeleted, Constants.ONE)
|
.set(DouyinProductSku::getEditDate, now)
|
.eq(DouyinProductSku::getProductId, info.getProductId())
|
.eq(DouyinProductSku::getIsdeleted, Constants.ZERO));
|
if (skuList != null) {
|
for (DouyinSkuDTO sku : skuList) {
|
DouyinProductSku s = new DouyinProductSku();
|
s.setId(ID.nextGUID());
|
s.setProductId(info.getProductId());
|
s.setSkuId(sku.getSkuId());
|
// SKU 标题取抖音 sku_name
|
s.setTitle(sku.getSkuName());
|
// 外部 SKU ID 取抖音 out_sku_id
|
s.setSkuOutId(sku.getSkuOutId());
|
// 市场价取抖音 origin_amount(原价/划线价,分)
|
s.setMarketPrice(sku.getOriginAmount());
|
// thirdSkuId / grouponType / voucherType 抖音 online/query 无对应字段,同步落 null
|
s.setCreateDate(now);
|
s.setIsdeleted(Constants.ZERO);
|
douyinProductSkuMapper.insert(s);
|
}
|
}
|
return true;
|
}
|
|
@Override
|
public PageData<DouyinProduct> findPage(PageWrap<DouyinProduct> pageWrap) {
|
IPage<DouyinProduct> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
|
MPJLambdaWrapper<DouyinProduct> wrapper = new MPJLambdaWrapper<>();
|
wrapper.selectAll(DouyinProduct.class)
|
// 套餐名:LEFT JOIN discount ON out_id=discount.id;未绑套餐(out_id 为空)→ discountName 为 null
|
.selectAs(Discount::getName, DouyinProduct::getDiscountName)
|
// 价格:未删除 SKU 的最低 market_price(分),无 SKU 为 null;主表别名 t
|
.select("(SELECT min(s.market_price) FROM \"douyin_product_sku\" s " +
|
"WHERE s.product_id = t.product_id AND s.isdeleted = 0)", DouyinProduct::getPrice)
|
// 已兑换数量:有效核销(verify_status=0 成功 + cancel_status=0 未撤销 + isdeleted=0 未删除)
|
.select("(SELECT count(1) FROM \"douyin_verify_record\" v " +
|
"WHERE v.product_id = t.product_id AND v.verify_status = 0 " +
|
"AND v.cancel_status = 0 AND v.isdeleted = 0)", DouyinProduct::getExchangedCount)
|
.leftJoin(Discount.class, Discount::getId, DouyinProduct::getOutId)
|
.eq(DouyinProduct::getIsdeleted, Constants.ZERO);
|
DouyinProduct m = pageWrap.getModel();
|
if (m != null) {
|
wrapper.like(StringUtils.isNotBlank(m.getProductName()), DouyinProduct::getProductName, m.getProductName())
|
// 套餐名筛选:跨 discount 表 like;LEFT JOIN + 右表非空条件,仅返回匹配套餐名的商品
|
.like(StringUtils.isNotBlank(m.getDiscountName()), Discount::getName, m.getDiscountName())
|
.eq(m.getOnlineStatus() != null, DouyinProduct::getOnlineStatus, m.getOnlineStatus())
|
.eq(StringUtils.isNotBlank(m.getProductId()), DouyinProduct::getProductId, m.getProductId())
|
.eq(StringUtils.isNotBlank(m.getOutId()), DouyinProduct::getOutId, m.getOutId())
|
.eq(StringUtils.isNotBlank(m.getAccountId()), DouyinProduct::getAccountId, m.getAccountId());
|
}
|
wrapper.orderByDesc(DouyinProduct::getSyncDate);
|
return PageData.from(douyinProductMapper.selectJoinPage(page, DouyinProduct.class, wrapper));
|
}
|
|
@Override
|
public DouyinProduct findById(String id) {
|
DouyinProduct p = douyinProductMapper.selectById(id);
|
if (p != null) {
|
List<DouyinProductSku> skus = douyinProductSkuMapper.selectList(new QueryWrapper<DouyinProductSku>().lambda()
|
.eq(DouyinProductSku::getProductId, p.getProductId())
|
.eq(DouyinProductSku::getIsdeleted, Constants.ZERO));
|
p.setSkus(skus);
|
if (StringUtils.isNotBlank(p.getOutId())) {
|
Discount discount = discountMapper.selectOne(new QueryWrapper<Discount>().lambda()
|
.eq(Discount::getId, p.getOutId())
|
.last("limit 1"));
|
if (discount != null) {
|
p.setDiscountName(discount.getName());
|
}
|
}
|
}
|
return p;
|
}
|
|
@Override
|
public void bindDiscount(String id, String discountId) {
|
DouyinProduct p = douyinProductMapper.selectById(id);
|
if (p == null || Constants.equalsInteger(p.getIsdeleted(), Constants.ONE)) {
|
throw new BusinessException(ResponseStatus.DATA_EMPTY);
|
}
|
// 非空时校验套餐存在且未删除;为空表示解绑
|
if (StringUtils.isNotBlank(discountId)) {
|
Discount discount = discountMapper.selectOne(new QueryWrapper<Discount>().lambda()
|
.eq(Discount::getId, discountId)
|
.eq(Discount::getIsdeleted, Constants.ZERO)
|
.last("limit 1"));
|
if (discount == null) {
|
throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "套餐不存在或已删除");
|
}
|
}
|
LoginUserInfo user = (LoginUserInfo) SecurityUtils.getSubject().getPrincipal();
|
Date now = new Date();
|
douyinProductMapper.update(null, new UpdateWrapper<DouyinProduct>().lambda()
|
.set(DouyinProduct::getOutId, StringUtils.isBlank(discountId) ? null : discountId)
|
.set(DouyinProduct::getEditDate, now)
|
.set(DouyinProduct::getEditor, user == null ? null : user.getId())
|
.eq(DouyinProduct::getId, id));
|
}
|
}
|