rk
5 天以前 84ae873e1c19ca7d2ffc5c98248285706dae818b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
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));
    }
}