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 实现。 *

覆盖验券三步链路: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 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); } /** * 验券(核销),成功后为当前操作人开通套餐卡(整单事务)。 *

方法级事务 {@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 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 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 反查本地套餐并为当前登录人开通套餐卡(含订单与开通日志)。 *

任一步失败抛异常 → 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().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().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().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().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 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().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 findPage(PageWrap pageWrap) { IPage page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity()); QueryWrapper 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 findManagePage(PageWrap pageWrap) { IPage page = new Page<>(pageWrap.getPage(), pageWrap.getCapacity()); MPJLambdaWrapper 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 result = douyinVerifyRecordMapper.selectJoinPage(page, DouyinVerifyRecordPageVO.class, wrapper); List 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 codes) { if (codes == null || codes.isEmpty()) { return null; } return String.join(",", codes); } }