package com.doumee.api.web; import com.alibaba.fastjson.JSON; import com.doumee.core.annotation.LoginRequired; import com.doumee.core.annotation.pr.PreventRepeat; 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.DouyinPrepareParam; import com.doumee.core.douyin.dto.DouyinPrepareResp; import com.doumee.core.douyin.dto.DouyinShopPoiResp; import com.doumee.core.douyin.dto.DouyinVerifyParam; import com.doumee.core.exception.BusinessException; import com.doumee.core.model.ApiResponse; import com.doumee.core.model.PageData; import com.doumee.core.model.PageWrap; import com.doumee.core.utils.ID; import com.doumee.dao.business.model.DouyinVerifyLog; import com.doumee.dao.business.model.DouyinVerifyRecord; import com.doumee.service.business.DouyinProductService; import com.doumee.service.business.DouyinVerifyLogService; import com.doumee.service.business.DouyinVerifyService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.stream.Collectors; /** * 抖音(小程序端:团购验券 + 联调测试) * * @author rk * @date 2026/06/24 */ @Slf4j @Api(tags = "抖音") @RestController @RequestMapping("/web/douyin") public class DouyinApi extends ApiController { @Autowired private DouyinProductService douyinProductService; @Autowired private DouyinVerifyService douyinVerifyService; @Autowired private DouyinVerifyLogService douyinVerifyLogService; /** 抖音 HTTP 客户端:门店查询测试用 */ @Autowired private DouyinClient douyinClient; @ApiOperation(value = "联调测试:从抖音全量同步商品入库(返回入库条数)", notes = "小程序端") @GetMapping("/testQuery") public ApiResponse testQuery() { // 全量翻页拉取 online/query 并 upsert 本地商品 + SKU,返回本次入库条数 return ApiResponse.success(douyinProductService.syncFromDouyin()); } @ApiOperation(value = "联调测试:查询抖音商户下门店ID列表(验证门店查询配置)", notes = "account_id 从字典读取") @GetMapping("/testPoiList") public ApiResponse> testPoiList() { // 门店查询为无状态透传,直接调 DouyinClient;account_id 由 Client 从字典读取 DouyinBaseResp resp = douyinClient.shopPoiQuery(); List pois = resp == null || resp.getData() == null ? null : resp.getData().getPois(); if (pois == null || pois.isEmpty()) { return ApiResponse.success(Collections.emptyList()); } // 仅提取门店ID,过滤 poi 节点或 poiId 为空的条目 List poiIds = pois.stream() .filter(p -> p != null && p.getPoi() != null && StringUtils.isNotBlank(p.getPoi().getPoiId())) .map(p -> p.getPoi().getPoiId()) .collect(Collectors.toList()); return ApiResponse.success(poiIds); } @LoginRequired @PreventRepeat @ApiOperation("扫码一步核销(验券准备 + 核销合并;前端只调此接口)") @PostMapping("/scanVerify") public ApiResponse scanVerify(@RequestBody DouyinPrepareParam param) { String apiPath = "/web/douyin/scanVerify"; String memberId = getMemberId(); // ① 验券准备:扫码/输码 → 拿 verifyToken 与券列表(单独记一条 PREPARE 日志) long prepareStart = System.currentTimeMillis(); DouyinVerifyLog prepareLog = baseLog(Constants.DOUYIN_VERIFY_OPERATE_TYPE.PREPARE.getKey(), apiPath, prepareStart); prepareLog.setRawRequest(JSON.toJSONString(param)); if (param != null) { prepareLog.setPoiId(param.getPoiId()); prepareLog.setOriginCode(StringUtils.firstNonBlank(param.getCode(), param.getQrContent())); } DouyinBaseResp prepareResp; try { prepareResp = douyinVerifyService.prepare(param); prepareLog.setRawResponse(JSON.toJSONString(prepareResp)); Integer code = prepareResp == null || prepareResp.getExtra() == null ? null : prepareResp.getExtra().getErrorCode(); prepareLog.setResult(code != null && code == 0 ? Constants.DOUYIN_VERIFY_LOG_RESULT.SUCCESS.getKey() : Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey()); if (code == null || code != 0) { String desc = prepareResp == null || prepareResp.getExtra() == null ? "无响应" : prepareResp.getExtra().getDescription(); throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "验券准备失败:" + desc); } } catch (Throwable e) { prepareLog.setResult(Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey()); prepareLog.setErrorMsg(e.getMessage()); throw e; } finally { saveLog(prepareLog); } // ② 取首张可核销券(canVerifyStatus=1 优先,否则首张),提取核销所需标识 DouyinPrepareResp prepareData = prepareResp.getData(); List certificates = prepareData == null ? null : prepareData.getCertificates(); if (certificates == null || certificates.isEmpty()) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "未找到可核销的券"); } DouyinPrepareResp.Certificate cert = pickFirstVerifiable(certificates); if (cert == null || cert.getSku() == null || StringUtils.isBlank(cert.getSku().getSkuId())) { throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "券缺少 SKU 信息,无法核销"); } // ③ 组装核销入参:verifyToken / 首张加密券码 / 门店 / skuId / 实付金额快照 DouyinVerifyParam verifyParam = new DouyinVerifyParam(); verifyParam.setVerifyToken(prepareData.getVerifyToken()); verifyParam.setPoiId(param == null ? null : param.getPoiId()); verifyParam.setEncryptedCodes(Collections.singletonList(cert.getEncryptedCode())); verifyParam.setSkuId(cert.getSku().getSkuId()); verifyParam.setPayAmount(cert.getAmount() == null ? null : cert.getAmount().getPayAmount()); // ④ 核销 + 开套餐(单独记一条 VERIFY 日志) long verifyStart = System.currentTimeMillis(); DouyinVerifyLog verifyLog = baseLog(Constants.DOUYIN_VERIFY_OPERATE_TYPE.VERIFY.getKey(), apiPath, verifyStart); verifyLog.setRawRequest(JSON.toJSONString(verifyParam)); verifyLog.setPoiId(verifyParam.getPoiId()); try { DouyinVerifyRecord rec = douyinVerifyService.verify(verifyParam, memberId); fillByRecord(verifyLog, rec); return ApiResponse.success(rec); } catch (Throwable e) { verifyLog.setResult(Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey()); verifyLog.setErrorMsg(e.getMessage()); throw e; } finally { saveLog(verifyLog); } } /** * 从券列表挑首张可核销的券(canVerifyStatus==1);都不满足则取首张,交给抖音核销接口判定。 * * @param certificates prepare 返回的券列表(已保证非空) * @return 首张可核销券;均不可核销时返回首张 */ private DouyinPrepareResp.Certificate pickFirstVerifiable(List certificates) { for (DouyinPrepareResp.Certificate cert : certificates) { if (cert != null && Constants.equalsInteger(cert.getCanVerifyStatus(), Constants.ONE)) { return cert; } } return certificates.get(0); } @LoginRequired @ApiOperation("核销记录分页") @PostMapping("/page") public ApiResponse> findPage(@RequestBody PageWrap pageWrap) { return ApiResponse.success(douyinVerifyService.findPage(pageWrap)); } @LoginRequired @ApiOperation("核销记录详情") @GetMapping("/{id}") public ApiResponse findById(@PathVariable String id) { return ApiResponse.success(douyinVerifyService.findById(id)); } // ---------------- 操作日志辅助 ---------------- private DouyinVerifyLog baseLog(int operateType, String apiPath, long start) { DouyinVerifyLog l = new DouyinVerifyLog(); l.setId(ID.nextGUID()); l.setOperateType(operateType); l.setApiPath(apiPath); l.setMemberId(getMemberId()); l.setIp(getRequest().getRemoteAddr()); l.setCostMs((int) (System.currentTimeMillis() - start)); l.setCreateDate(new Date()); l.setIsdeleted(Constants.ZERO); return l; } /** verify 成功后,用核销记录回填日志的业务字段与结果(撤销核销已迁移至管理端) */ private void fillByRecord(DouyinVerifyLog opLog, DouyinVerifyRecord rec) { if (rec == null) { opLog.setResult(Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey()); return; } opLog.setVerifyRecordId(rec.getId()); if (StringUtils.isNotBlank(rec.getPoiId())) { opLog.setPoiId(rec.getPoiId()); } opLog.setOriginCode(rec.getOriginCode()); opLog.setRawResponse(rec.getRawResponse()); opLog.setResult(Constants.equalsInteger(rec.getVerifyStatus(), Constants.ZERO) ? Constants.DOUYIN_VERIFY_LOG_RESULT.SUCCESS.getKey() : Constants.DOUYIN_VERIFY_LOG_RESULT.FAIL.getKey()); opLog.setErrorMsg(rec.getVerifyMsg()); } /** 落库操作日志;日志自身异常不抛出,避免影响主流程 */ private void saveLog(DouyinVerifyLog opLog) { try { douyinVerifyLogService.record(opLog); } catch (Exception e) { log.warn("记录抖音验券操作日志失败 type={}, recordId={}", opLog.getOperateType(), opLog.getVerifyRecordId(), e); } } }