MrShi
8 小时以前 9eeb62c02a7b3c7b95c20678b6a9c74e7f12f943
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
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
package com.doumee.service.business.impl;
 
import com.alibaba.fastjson.JSON;
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.DouyinCancelParam;
import com.doumee.core.douyin.dto.DouyinCancelReq;
import com.doumee.core.douyin.dto.DouyinCancelResp;
import com.doumee.core.douyin.dto.DouyinPrepareParam;
import com.doumee.core.douyin.dto.DouyinPrepareReq;
import com.doumee.core.douyin.dto.DouyinPrepareResp;
import com.doumee.core.douyin.dto.DouyinVerifyParam;
import com.doumee.core.douyin.dto.DouyinVerifyReq;
import com.doumee.core.douyin.dto.DouyinVerifyResp;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.model.PageData;
import com.doumee.core.model.PageWrap;
import com.doumee.core.utils.DateUtil;
import com.doumee.core.utils.ID;
import com.doumee.dao.business.DiscountLogMapper;
import com.doumee.dao.business.DiscountMapper;
import com.doumee.dao.business.DiscountMemberMapper;
import com.doumee.dao.business.DouyinProductMapper;
import com.doumee.dao.business.DouyinProductSkuMapper;
import com.doumee.dao.business.DouyinVerifyRecordMapper;
import com.doumee.dao.business.GoodsorderMapper;
import com.doumee.dao.business.model.Discount;
import com.doumee.dao.business.model.DiscountLog;
import com.doumee.dao.business.model.DiscountMember;
import com.doumee.dao.business.model.DouyinProduct;
import com.doumee.dao.business.model.DouyinProductSku;
import com.doumee.dao.business.model.DouyinVerifyRecord;
import com.doumee.dao.business.model.Goodsorder;
import com.doumee.dao.business.model.Member;
import com.doumee.dao.business.vo.DouyinVerifyRecordPageVO;
import com.doumee.service.business.DouyinVerifyService;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
 
/**
 * 抖音券核销 Service 实现。
 * <p>覆盖验券三步链路:prepare(验券准备,扫码/输码拿 verifyToken 与券列表)
 * → verify(核销,落核销记录 + 开通套餐)→ cancel(核销后 1 小时内撤销)。
 * 操作人 operator 由调用端传入(web 端取登录会员 id),service 不依赖任何鉴权框架。
 *
 * @author rk
 * @date 2026/06/22
 */
@Slf4j
@Service
public class DouyinVerifyServiceImpl implements DouyinVerifyService {
 
    @Autowired
    private DouyinClient douyinClient;
    @Autowired
    private DouyinVerifyRecordMapper douyinVerifyRecordMapper;
    @Autowired
    private DouyinProductSkuMapper douyinProductSkuMapper;
    @Autowired
    private DouyinProductMapper douyinProductMapper;
    @Autowired
    private DiscountMapper discountMapper;
    @Autowired
    private DiscountMemberMapper discountMemberMapper;
    @Autowired
    private GoodsorderMapper goodsorderMapper;
    @Autowired
    private DiscountLogMapper discountLogMapper;
 
    /** 抖音验券接口返回的核销结果码:0成功(非本地表字段,不并入 Constants 枚举) */
    private static final int VERIFY_OK = 0;
 
    /** goodsorder 交易类型:套餐卡购买 */
    private static final int GOODSORDER_TYPE_DISCOUNT = 1;
    /** goodsorder 关联对象类型:套餐卡 */
    private static final int GOODSORDER_OBJ_TYPE_DISCOUNT = 0;
    /** goodsorder 已支付状态(订单状态 / 支付状态均为 1) */
    private static final int GOODSORDER_PAID = 1;
    /** 支付方式:抖音券核销(需前端支付方式字典配合展示) */
    private static final int PAY_WAY_DOUYIN = 2;
    /** discount_log 操作类型:平台调整 */
    private static final int DISCOUNT_LOG_TYPE_ADJUST = 2;
 
    @Override
    public DouyinBaseResp<DouyinPrepareResp> prepare(DouyinPrepareParam param) {
        // poiId 为空时兜底取字典配置(单门店,后台可改)
        if (param != null && StringUtils.isBlank(param.getPoiId())) {
            param.setPoiId(douyinClient.getPoiId());
        }
        // 入参校验:门店 + (二维码 | 券码) 必填
        if (param == null || StringUtils.isBlank(param.getPoiId())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "poiId 未配置(前端未传且字典 POI_ID 为空)");
        }
        if (StringUtils.isBlank(param.getQrContent()) && StringUtils.isBlank(param.getCode())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "qrContent 与 code 至少传一个");
        }
        DouyinPrepareReq req = new DouyinPrepareReq();
        req.setPoiId(param.getPoiId());
        // 券码明文(手动输入)场景
        if (StringUtils.isNotBlank(param.getCode())) {
            req.setCode(param.getCode());
        }
        // 扫码场景:先把短链 / 含 object_id 的长链解析成 encrypted_data
        if (StringUtils.isNotBlank(param.getQrContent())) {
            String encryptedData = douyinClient.resolveShortLink(param.getQrContent());
            // 解析不到就把原文当 encrypted_data 兜底交给抖音
            req.setEncryptedData(StringUtils.isBlank(encryptedData) ? param.getQrContent() : encryptedData);
        }
        return douyinClient.prepare(req);
    }
 
    /**
     * 验券(核销),成功后为当前操作人开通套餐卡(整单事务)。
     * <p>方法级事务 {@code @Transactional}:抖音核销接口失败、券不可核销、开套餐任一环节异常,
     * 均整单回滚(核销记录 / 套餐卡 / 订单 / 日志同生共灭)。controller 层 {@code douyin_verify_log}
     * 在 finally 独立保存,仍留痕便于事后凭券码补开。
     *
     * @param param    核销入参(verifyToken/poiId/encryptedCodes + skuId 反查套餐)
     * @param operator 操作人 = 套餐归属人(web 端登录会员 id)
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public DouyinVerifyRecord verify(DouyinVerifyParam param, String operator) {
        // 入参校验
        if (param == null || StringUtils.isBlank(param.getVerifyToken())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "verifyToken 不能为空");
        }
        // poiId 为空时兜底取字典配置(单门店,后台可改)
        if (StringUtils.isBlank(param.getPoiId())) {
            param.setPoiId(douyinClient.getPoiId());
        }
        if (StringUtils.isBlank(param.getPoiId())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "poiId 未配置(前端未传且字典 POI_ID 为空)");
        }
        if (param.getEncryptedCodes() == null || param.getEncryptedCodes().isEmpty()) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "encryptedCodes 不能为空");
        }
        if (StringUtils.isBlank(param.getSkuId())) {
            // 无 skuId 则无法反查套餐(核销返回本身不含商品标识),直接拦截
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "skuId 不能为空");
        }
        Date now = new Date();
 
        // 组装抖音验券请求
        DouyinVerifyReq req = new DouyinVerifyReq();
        req.setVerifyToken(param.getVerifyToken());
        req.setPoiId(param.getPoiId());
        req.setEncryptedCodes(param.getEncryptedCodes());
        req.setAccountId(param.getAccountId());
 
        // 调用抖音验券
        DouyinBaseResp<DouyinVerifyResp> resp = douyinClient.verify(req);
        String respText = JSON.toJSONString(resp);
 
        // 接口级失败(extra.errorCode 非 0):整单回滚,由 controller 日志留痕
        Integer extraCode = resp == null || resp.getExtra() == null ? null : resp.getExtra().getErrorCode();
        if (extraCode == null || extraCode != 0) {
            String desc = resp == null || resp.getExtra() == null ? "无响应" : resp.getExtra().getDescription();
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "验券失败:" + desc);
        }
 
        // 接口成功,取首张券的核销结果(当前按首张处理)
        DouyinVerifyResp data = resp.getData();
        List<DouyinVerifyResp.VerifyResult> results = data == null ? null : data.getVerifyResults();
        DouyinVerifyResp.VerifyResult first = (results == null || results.isEmpty()) ? null : results.get(0);
        boolean ok = first != null && first.getResult() != null && first.getResult() == VERIFY_OK;
 
        // 落核销记录(成功 / 失败都先落;券不可核销时随事务回滚)
        DouyinVerifyRecord rec = baseRecord(req, respText, operator, now);
        rec.setVerifyStatus(ok ? Constants.DOUYIN_VERIFY_STATUS.SUCCESS.getKey() : Constants.DOUYIN_VERIFY_STATUS.FAIL.getKey());
        rec.setVerifyMsg(first == null ? (data == null ? null : data.getDescription()) : first.getMsg());
        rec.setPoiId(param.getPoiId());
        rec.setAccountId(first != null ? first.getAccountId() : param.getAccountId());
        rec.setEncryptedCode(joinCodes(param.getEncryptedCodes()));
        if (first != null) {
            // 快照抖音返回的核销关键标识,撤销核销时要用
            rec.setVerifyId(first.getVerifyId());
            rec.setCertificateId(first.getCertificateId());
            rec.setOriginCode(first.getOriginCode());
            rec.setOrderId(first.getOrderId());
        }
        rec.setCancelStatus(Constants.ZERO);
        douyinVerifyRecordMapper.insert(rec);
 
        // 接口成功但券本身不可核销(如已核销 / 已退款):抛出,整单回滚
        if (!ok) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),
                    "验券失败:" + (rec.getVerifyMsg() == null ? "未知原因" : rec.getVerifyMsg()));
        }
 
        // 核销成功:为当前登录人开通套餐(任一步失败 → 整单回滚)
        openDiscountForVerify(rec, param, operator);
        return rec;
    }
 
    /**
     * 核销成功后,按 skuId 反查本地套餐并为当前登录人开通套餐卡(含订单与开通日志)。
     * <p>任一步失败抛异常 → verify 的 {@code @Transactional} 整单回滚(核销记录一并回滚);
     * controller 层 {@code douyin_verify_log} 在 finally 独立保存,仍留痕便于事后补开。
     *
     * @param rec      核销记录(含 originCode/certificateId 等抖音标识,开通后回填套餐卡ID)
     * @param param    核销入参(含 skuId 反查套餐、payAmount 快照)
     * @param operator 操作人 = 套餐归属人(web 端登录会员 id)
     */
    private void openDiscountForVerify(DouyinVerifyRecord rec, DouyinVerifyParam param, String operator) {
        // ① 反查套餐:skuId → douyin_product_sku → product_id → douyin_product.out_id → discount
        DouyinProduct product = resolveProduct(param.getSkuId());
        Discount discount = resolveDiscount(product);
 
        Date now = new Date();
 
        // ② 防重:同一券码已为该用户开过套餐卡则跳过(避免重复核销重开)
        DiscountMember existCard = discountMemberMapper.selectOne(new QueryWrapper<DiscountMember>().lambda()
                .eq(DiscountMember::getCode, rec.getOriginCode())
                .eq(DiscountMember::getMemberId, operator)
                .eq(DiscountMember::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        if (existCard != null) {
            log.warn("该券码已开通套餐,跳过重复开卡 originCode={}", rec.getOriginCode());
            rec.setDiscountMemberId(existCard.getId());
            douyinVerifyRecordMapper.updateById(rec);
            return;
        }
 
        // ③ 主键
        String goodsorderId = Constants.getUUID();
        String discountMemberId = Constants.getUUID();
 
        // ④ 开 discount_member(复用 createDiscountOrderPay 的开卡段,直接置已支付)
        DiscountMember dm = new DiscountMember();
        BeanUtils.copyProperties(discount, dm);
        dm.setId(discountMemberId);
        dm.setCreateDate(now);
        dm.setEditDate(now);
        dm.setCreator(null);
        dm.setEditor(null);
        dm.setMemberId(operator);
        dm.setCode(rec.getOriginCode());              // 原始券码当票号
        dm.setGoodsorderId(goodsorderId);
        dm.setStatus(Constants.ZERO);                 // 正常,核销即视为已支付
        // 有效期:useType != 0(非固定时间段)时按购买逻辑计算
        if (!Constants.equalsInteger(dm.getUseType(), Constants.ZERO)) {
            if (Constants.equalsInteger(dm.getUseType(), Constants.ONE)) {
                // 购买后生效:使用开始 = 今天
                dm.setUseStartDate(DateUtil.StringToDateFormat(DateUtil.getCurrDate(), "yyyy-MM-dd"));
            }
            // 使用结束 = 使用开始 + (useDays - 1)
            dm.setUseEndDate(DateUtil.StringToDateFormat(
                    DateUtil.getXDaysAfter(dm.getUseStartDate(), dm.getUseDays() - 1), "yyyy-MM-dd"));
        }
        discountMemberMapper.insert(dm);
 
        // ⑤ 建 goodsorder(对齐支付回调,直接置已支付)
        Goodsorder goodsorder = new Goodsorder();
        goodsorder.setId(goodsorderId);
        goodsorder.setCode(goodsorderId);
        goodsorder.setCreateDate(now);
        goodsorder.setIsdeleted(Constants.ZERO);
        goodsorder.setMemberId(operator);
        goodsorder.setType(GOODSORDER_TYPE_DISCOUNT);       // 1 套餐卡购买
        goodsorder.setObjType(GOODSORDER_OBJ_TYPE_DISCOUNT); // 0 套餐卡
        goodsorder.setObjId(discount.getId());
        goodsorder.setMoney(BigDecimal.ZERO);               // 核销免费兑换,平台无实收
        goodsorder.setStatus(GOODSORDER_PAID);              // 1 已支付
        goodsorder.setPayStatus(GOODSORDER_PAID);           // 1 已支付
        goodsorder.setPayWay(PAY_WAY_DOUYIN);               // 2 抖音券核销
        goodsorder.setPayDate(now);
        goodsorder.setInfo("抖音券核销兑换");
        goodsorderMapper.insert(goodsorder);
 
        // ⑥ discount_log 开通日志(平台调整)
        DiscountLog discountLog = new DiscountLog();
        discountLog.setId(Constants.getUUID());
        discountLog.setCreateDate(now);
        discountLog.setCreator(operator);
        discountLog.setIsdeleted(Constants.ZERO);
        discountLog.setDiscountMemberId(discountMemberId);
        discountLog.setGoodsorderId(goodsorderId);
        discountLog.setType(DISCOUNT_LOG_TYPE_ADJUST);      // 2 平台调整
        discountLog.setInfo("抖音券核销开通,券码 " + rec.getOriginCode());
        discountLogMapper.insert(discountLog);
 
        // ⑦ 回填核销记录(商品快照 + 套餐卡ID)
        rec.setProductId(product.getProductId());
        rec.setProductName(product.getProductName());
        if (param.getPayAmount() != null) {
            rec.setPayAmount(param.getPayAmount());
        }
        rec.setDiscountMemberId(discountMemberId);
        douyinVerifyRecordMapper.updateById(rec);
    }
 
    /**
     * 按 skuId 反查抖音商品(product_id / out_id 的来源)。
     * 链路:skuId → douyin_product_sku(sku_id,isdeleted=0) → product_id
     *
     * @param skuId 核销券对应的抖音 SKU ID
     * @return 抖音商品;查不到抛「未找到套餐」业务异常(触发整单回滚)
     */
    private DouyinProduct resolveProduct(String skuId) {
        DouyinProductSku sku = douyinProductSkuMapper.selectOne(new QueryWrapper<DouyinProductSku>().lambda()
                .eq(DouyinProductSku::getSkuId, skuId)
                .eq(DouyinProductSku::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        if (sku == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "未找到该券对应的本地套餐,请先在管理端绑定");
        }
        DouyinProduct product = douyinProductMapper.selectOne(new QueryWrapper<DouyinProduct>().lambda()
                .eq(DouyinProduct::getProductId, sku.getProductId())
                .eq(DouyinProduct::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        if (product == null || StringUtils.isBlank(product.getOutId())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "未找到该券对应的本地套餐,请先在管理端绑定");
        }
        return product;
    }
 
    /**
     * 按已查到的抖音商品反查本地套餐。
     * 链路:douyin_product.out_id → discount(id=out_id, status=0 正常, isdeleted=0)
     *
     * @param product 已反查到的抖音商品(需有 out_id)
     * @return 本地套餐;查不到抛「未找到套餐」业务异常
     */
    private Discount resolveDiscount(DouyinProduct product) {
        Discount discount = discountMapper.selectOne(new QueryWrapper<Discount>().lambda()
                .eq(Discount::getId, product.getOutId())
                .eq(Discount::getStatus, Constants.ZERO)
                .eq(Discount::getIsdeleted, Constants.ZERO)
                .last("limit 1"));
        if (discount == null) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "未找到该券对应的本地套餐,请先在管理端绑定");
        }
        return discount;
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public DouyinVerifyRecord cancel(DouyinCancelParam param, String operator) {
        if (param == null || StringUtils.isBlank(param.getId())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "id 不能为空");
        }
        // 取本地核销记录
        DouyinVerifyRecord rec = douyinVerifyRecordMapper.selectById(param.getId());
        if (rec == null || Constants.equalsInteger(rec.getIsdeleted(), Constants.ONE)) {
            throw new BusinessException(ResponseStatus.DATA_EMPTY);
        }
        // 已撤销,防重复操作
        if (Constants.equalsInteger(rec.getCancelStatus(), Constants.DOUYIN_VERIFY_CANCEL_STATUS.DONE.getKey())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "该记录已撤销,请勿重复操作");
        }
        // 只有核销成功的记录才能撤销(管理端运营操作,不再受"核销后1小时内"时间窗限制)
        if (!Constants.equalsInteger(rec.getVerifyStatus(), Constants.DOUYIN_VERIFY_STATUS.SUCCESS.getKey())) {
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "仅成功核销的记录可撤销");
        }
 
        Date now = new Date();
 
        // 用核销时拿到的标识去抖音撤销
        DouyinCancelReq req = new DouyinCancelReq();
        req.setCertificateId(rec.getCertificateId());
        req.setVerifyId(rec.getVerifyId());
        req.setAccountId(rec.getAccountId());
 
        DouyinBaseResp<DouyinCancelResp> resp = douyinClient.cancel(req);
        // 成功判据:外层 extra 与 data 的 error_code 都为 0
        Integer extraCode = resp == null || resp.getExtra() == null ? null : resp.getExtra().getErrorCode();
        Integer dataCode = resp == null || resp.getData() == null ? null : resp.getData().getErrorCode();
        boolean ok = extraCode != null && extraCode == 0 && dataCode != null && dataCode == 0;
 
        String respText = JSON.toJSONString(resp);
        rec.setEditDate(now);
        rec.setEditor(operator);
        rec.setRawResponse(respText);
        // 撤销失败:更新记录描述后抛出(记录保留原核销状态)
        if (!ok) {
            String desc = resp == null || resp.getExtra() == null ? "无响应" : resp.getExtra().getDescription();
            rec.setCancelMsg("撤销失败:" + desc);
            douyinVerifyRecordMapper.updateById(rec);
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "撤销失败:" + desc);
        }
        // 撤销成功:置撤销状态、撤销时间与撤销人
        rec.setCancelStatus(Constants.DOUYIN_VERIFY_CANCEL_STATUS.DONE.getKey());
        rec.setCancelTime(now);
        rec.setCancelUserId(operator);
        rec.setCancelMsg("撤销成功");
        douyinVerifyRecordMapper.updateById(rec);
        // 同步作废本地已开通的套餐卡(防止抖音撤销后套餐仍被使用);参照 backGoodsorder 退卡
        cancelDiscountMember(rec, operator, now);
        return rec;
    }
 
    /**
     * 撤销核销后作废关联的套餐卡:仅正常(status=0)套餐卡作废,已作废跳过(幂等);记 discount_log(平台作废)。
     *
     * @param rec      核销记录(含 discountMemberId)
     * @param operator 撤销操作人(管理端 Shiro 登录用户 id)
     * @param now      撤销时间
     */
    private void cancelDiscountMember(DouyinVerifyRecord rec, String operator, Date now) {
        if (StringUtils.isBlank(rec.getDiscountMemberId())) {
            return;
        }
        DiscountMember dm = discountMemberMapper.selectById(rec.getDiscountMemberId());
        // 无套餐卡或已作废,跳过(幂等)
        if (dm == null || !Constants.equalsInteger(dm.getStatus(), Constants.ZERO)) {
            return;
        }
        discountMemberMapper.update(null, new UpdateWrapper<DiscountMember>().lambda()
                .set(DiscountMember::getStatus, Constants.ONE)      // 1 作废
                .set(DiscountMember::getEditDate, now)
                .set(DiscountMember::getEditor, operator)
                .eq(DiscountMember::getId, dm.getId()));
        DiscountLog discountLog = new DiscountLog();
        discountLog.setId(Constants.getUUID());
        discountLog.setCreateDate(now);
        discountLog.setCreator(operator);
        discountLog.setIsdeleted(Constants.ZERO);
        discountLog.setDiscountMemberId(dm.getId());
        discountLog.setType(Constants.ONE);                        // 1 平台作废
        discountLog.setEditInfo("撤销核销作废");
        discountLog.setGoodsorderId(dm.getGoodsorderId());
        discountLogMapper.insert(discountLog);
    }
 
    @Override
    public PageData<DouyinVerifyRecord> findPage(PageWrap<DouyinVerifyRecord> pageWrap) {
        IPage<DouyinVerifyRecord> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
        QueryWrapper<DouyinVerifyRecord> wrapper = new QueryWrapper<>();
        // 仅查未删除
        wrapper.lambda().eq(DouyinVerifyRecord::getIsdeleted, Constants.ZERO);
        DouyinVerifyRecord m = pageWrap.getModel();
        // 按查询条件逐项精确匹配(非空才拼接)
        if (m != null) {
            if (StringUtils.isNotBlank(m.getVerifyId())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getVerifyId, m.getVerifyId());
            }
            if (StringUtils.isNotBlank(m.getCertificateId())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getCertificateId, m.getCertificateId());
            }
            if (StringUtils.isNotBlank(m.getOriginCode())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getOriginCode, m.getOriginCode());
            }
            if (StringUtils.isNotBlank(m.getOrderId())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getOrderId, m.getOrderId());
            }
            if (StringUtils.isNotBlank(m.getPoiId())) {
                wrapper.lambda().eq(DouyinVerifyRecord::getPoiId, m.getPoiId());
            }
            if (m.getVerifyStatus() != null) {
                wrapper.lambda().eq(DouyinVerifyRecord::getVerifyStatus, m.getVerifyStatus());
            }
            if (m.getCancelStatus() != null) {
                wrapper.lambda().eq(DouyinVerifyRecord::getCancelStatus, m.getCancelStatus());
            }
        }
        // 默认按核销时间倒序
        wrapper.lambda().orderByDesc(DouyinVerifyRecord::getVerifyTime);
        return PageData.from(douyinVerifyRecordMapper.selectPage(page, wrapper));
    }
 
    @Override
    public PageData<DouyinVerifyRecordPageVO> findManagePage(PageWrap<DouyinVerifyRecordPageVO> pageWrap) {
        IPage<DouyinVerifyRecordPageVO> page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity());
        MPJLambdaWrapper<DouyinVerifyRecord> wrapper = new MPJLambdaWrapper<>();
        // 显式选主表列(避开 product_name 快照,团购商品名改用 join 的 douyin_product.product_name)
        wrapper.select(DouyinVerifyRecord::getId)
                .select(DouyinVerifyRecord::getOriginCode)
                .select(DouyinVerifyRecord::getVerifyTime)
                .select(DouyinVerifyRecord::getVerifyStatus)
                .select(DouyinVerifyRecord::getCancelStatus)
                // 订单编号:discount_member.goodsorder_id(核销时自动建的 goodsorder 订单)
                .selectAs(DiscountMember::getGoodsorderId, DouyinVerifyRecordPageVO::getOrderCode)
                // 会员 openid/手机号/兑换人姓名:member
                .selectAs(Member::getOpenid, DouyinVerifyRecordPageVO::getMemberOpenid)
                .selectAs(Member::getPhone, DouyinVerifyRecordPageVO::getMemberPhone)
                .selectAs(Member::getName, DouyinVerifyRecordPageVO::getExchangerName)
                // 团购商品名/类目:douyin_product(经 product_id 关联,非主键字段)
                .selectAs(DouyinProduct::getProductName, DouyinVerifyRecordPageVO::getProductName)
                .selectAs(DouyinProduct::getCategory, DouyinVerifyRecordPageVO::getCategory)
                // 抖音券名:discount_member.name(本地开通套餐名)
                .selectAs(DiscountMember::getName, DouyinVerifyRecordPageVO::getCouponName)
                // 三表 leftJoin:discount_member(经 discount_member_id)→ member(经 member_id);douyin_product(经 product_id)
                .leftJoin(DiscountMember.class, DiscountMember::getId, DouyinVerifyRecord::getDiscountMemberId)
                .leftJoin(Member.class, Member::getId, DiscountMember::getMemberId)
                .leftJoin(DouyinProduct.class, DouyinProduct::getProductId, DouyinVerifyRecord::getProductId)
                .eq(DouyinVerifyRecord::getIsdeleted, Constants.ZERO);
        DouyinVerifyRecordPageVO m = pageWrap.getModel();
        if (m != null) {
            // 查询条件:抖音券码(精确)、验券状态、撤销状态
            wrapper.eq(StringUtils.isNotBlank(m.getOriginCode()), DouyinVerifyRecord::getOriginCode, m.getOriginCode())
                    .eq(m.getVerifyStatus() != null, DouyinVerifyRecord::getVerifyStatus, m.getVerifyStatus())
                    .eq(m.getCancelStatus() != null, DouyinVerifyRecord::getCancelStatus, m.getCancelStatus());
        }
        wrapper.orderByDesc(DouyinVerifyRecord::getVerifyTime);
        IPage<DouyinVerifyRecordPageVO> result = douyinVerifyRecordMapper.selectJoinPage(page, DouyinVerifyRecordPageVO.class, wrapper);
        List<DouyinVerifyRecordPageVO> records = result.getRecords();
        if (records != null) {
            for (DouyinVerifyRecordPageVO vo : records) {
                // 手机号脱敏 + 状态文案(内存回填,非逐行查询)
                vo.setMemberPhone(maskPhone(vo.getMemberPhone()));
                vo.setStatusName(statusName(vo.getVerifyStatus(), vo.getCancelStatus()));
            }
        }
        return PageData.from(result);
    }
 
    /** 手机号脱敏:138****1234(前3后4,中间4位*);长度 < 7 原样返回 */
    private String maskPhone(String phone) {
        if (StringUtils.isBlank(phone) || phone.length() < 7) {
            return phone;
        }
        return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
    }
 
    /** 核销状态文案:已撤销 > 核销失败 > 已兑换 */
    private String statusName(Integer verifyStatus, Integer cancelStatus) {
        if (Constants.equalsInteger(cancelStatus, Constants.ONE)) {
            return "已撤销";
        }
        if (Constants.equalsInteger(verifyStatus, Constants.ONE)) {
            return "核销失败";
        }
        return "已兑换";
    }
 
    @Override
    public DouyinVerifyRecord findById(String id) {
        return douyinVerifyRecordMapper.selectById(id);
    }
 
    /**
     * 构造一条核销记录的公共字段(主键 / 时间 / 操作人 / 请求响应快照 / 删除与撤销初值)
     */
    private DouyinVerifyRecord baseRecord(DouyinVerifyReq req, String respText, String operator, Date now) {
        DouyinVerifyRecord rec = new DouyinVerifyRecord();
        rec.setId(ID.nextGUID());
        rec.setVerifyTime(now);
        rec.setVerifyUserId(operator);
        rec.setCreateDate(now);
        rec.setCreator(operator);
        rec.setIsdeleted(Constants.ZERO);
        rec.setCancelStatus(Constants.ZERO);
        rec.setRawRequest(JSON.toJSONString(req));
        rec.setRawResponse(respText);
        return rec;
    }
 
    /**
     * 把加密券码列表拼成逗号分隔字符串,便于单字段存储
     */
    private String joinCodes(List<String> codes) {
        if (codes == null || codes.isEmpty()) {
            return null;
        }
        return String.join(",", codes);
    }
}