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 syncedProductIds = new HashSet<>(); String cursor = null; while (true) { DouyinOnlineQueryReq req = new DouyinOnlineQueryReq(); req.setCursor(cursor); req.setCount(PAGE_SIZE); DouyinBaseResp 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 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().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 采用「逻辑删除旧的 + 插入新的」全量覆盖。 *

抖音 online/query 的商品基础信息藏在 products[].product 子对象里(见 {@link DouyinProductDTO}), * 故商品字段一律从 dto.product 取;SKU 列表取自顶层 dto.skus。 *

无 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 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().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().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 findPage(PageWrap pageWrap) { IPage page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity()); MPJLambdaWrapper 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 skus = douyinProductSkuMapper.selectList(new QueryWrapper().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().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().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().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)); } }