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
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<Integer> testQuery() {
        // 全量翻页拉取 online/query 并 upsert 本地商品 + SKU,返回本次入库条数
        return ApiResponse.success(douyinProductService.syncFromDouyin());
    }
 
    @ApiOperation(value = "联调测试:查询抖音商户下门店ID列表(验证门店查询配置)", notes = "account_id 从字典读取")
    @GetMapping("/testPoiList")
    public ApiResponse<List<String>> testPoiList() {
        // 门店查询为无状态透传,直接调 DouyinClient;account_id 由 Client 从字典读取
        DouyinBaseResp<DouyinShopPoiResp> resp = douyinClient.shopPoiQuery();
        List<DouyinShopPoiResp.Poi> pois = resp == null || resp.getData() == null ? null : resp.getData().getPois();
        if (pois == null || pois.isEmpty()) {
            return ApiResponse.success(Collections.emptyList());
        }
        // 仅提取门店ID,过滤 poi 节点或 poiId 为空的条目
        List<String> 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<DouyinVerifyRecord> 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<DouyinPrepareResp> 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<DouyinPrepareResp.Certificate> 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<DouyinPrepareResp.Certificate> 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<PageData<DouyinVerifyRecord>> findPage(@RequestBody PageWrap<DouyinVerifyRecord> pageWrap) {
        return ApiResponse.success(douyinVerifyService.findPage(pageWrap));
    }
 
    @LoginRequired
    @ApiOperation("核销记录详情")
    @GetMapping("/{id}")
    public ApiResponse<DouyinVerifyRecord> 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);
        }
    }
}