MrShi
13 小时以前 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
package com.doumee.core.douyin;
 
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.TypeReference;
import com.doumee.core.constants.Constants;
import com.doumee.core.constants.ResponseStatus;
import com.doumee.core.douyin.dto.DouyinBaseResp;
import com.doumee.core.douyin.dto.DouyinCancelReq;
import com.doumee.core.douyin.dto.DouyinCancelResp;
import com.doumee.core.douyin.dto.DouyinClientTokenReq;
import com.doumee.core.douyin.dto.DouyinClientTokenResp;
import com.doumee.core.douyin.dto.DouyinOnlineQueryReq;
import com.doumee.core.douyin.dto.DouyinOnlineQueryResp;
import com.doumee.core.douyin.dto.DouyinPrepareReq;
import com.doumee.core.douyin.dto.DouyinPrepareResp;
import com.doumee.core.douyin.dto.DouyinShopPoiResp;
import com.doumee.core.douyin.dto.DouyinVerifyReq;
import com.doumee.core.douyin.dto.DouyinVerifyResp;
import com.doumee.core.exception.BusinessException;
import com.doumee.core.utils.Http;
import com.doumee.biz.system.SystemDictDataBiz;
import com.doumee.dao.system.model.SystemDictData;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
 
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
 
/**
 * 抖音开放平台 HTTP 客户端
 * 封装 client_token 获取(带 Redis 缓存,提前刷新)与 goodlife/v1 接口调用。
 *
 * @author rk
 * @date 2026/06/22
 */
@Slf4j
@Component
public class DouyinClient {
 
    @Autowired
    private DouyinProperties douyinProperties;
 
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
 
    /** 字典取数(后台可改抖音配置:client_key/client_secret/account_id/poi_id,免重启) */
    @Autowired
    private SystemDictDataBiz systemDictDataBiz;
 
    /**
     * 从字典 DOUYIN_CONFIG 取指定标签的值。
     * 字典无值(返回空对象)时兜底返回 null,由调用方校验。
     *
     * @param label 字典项标签(如 CLIENT_KEY / POI_ID)
     * @return 配置值;查不到返回 null
     */
    private String getDictValue(String label) {
        SystemDictData data = systemDictDataBiz.queryByCode(Constants.DOUYIN_CONFIG, label);
        return data == null ? null : data.getCode();
    }
 
    /**
     * 取核销门店ID(单门店,存字典 POI_ID)。
     *
     * @return 门店ID
     */
    public String getPoiId() {
        return getDictValue(Constants.DOUYIN_POI_ID);
    }
 
    // 官方 API 文档(生活服务 / 团购核销):
    // https://partner.open-douyin.com/docs/resource/zh-CN/local-life/develop/OpenAPI/general-capabilities/life.capacity.fulfilment/certificate.prepare
    /** 「生成 client_token」接口路径 */
    private static final String URL_CLIENT_TOKEN = "/oauth/client_token/";
    /** 「查询商品线上数据列表」接口路径 */
    private static final String URL_PRODUCT_ONLINE_QUERY = "/goodlife/v1/goods/product/online/query/";
    /** 「查询门店信息」接口路径(查询商户下已认领的门店列表) */
    private static final String URL_SHOP_POI_QUERY = "/goodlife/v1/shop/poi/query/";
    /** 「验券准备」接口路径 */
    private static final String URL_PREPARE = "/goodlife/v1/fulfilment/certificate/prepare/";
    /** 「验券(核销)」接口路径 */
    private static final String URL_VERIFY = "/goodlife/v1/fulfilment/certificate/verify/";
    /** 「撤销核销」接口路径 */
    private static final String URL_CANCEL = "/goodlife/v1/fulfilment/certificate/cancel/";
 
    /** token 提前刷新余量(秒),避免临界过期 */
    private static final long TOKEN_REFRESH_LEAD_SECONDS = 300L;
    /** client_token 默认有效期(秒),接口未返回时兜底 */
    private static final long TOKEN_DEFAULT_EXPIRE_SECONDS = 7200L;
 
    /** access_token 无效 / 过期错误码 */
    private static final int ERR_TOKEN_INVALID = 2190002;
    private static final int ERR_TOKEN_EXPIRED = 2190008;
 
    // ============================ client_token ============================
 
    /**
     * 获取 access-token(带 Redis 缓存,临近过期自动刷新)
     */
    public String getAccessToken() {
        String key = douyinProperties.getRedisTokenKey();
        Object cached = redisTemplate.opsForValue().get(key);
        if (cached instanceof String) {
            String token = (String) cached;
            Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
            if (StringUtils.isNotBlank(token) && ttl != null && ttl > TOKEN_REFRESH_LEAD_SECONDS) {
                return token;
            }
        }
        return refreshAccessToken();
    }
 
    /**
     * 强制刷新 access-token
     */
    public String refreshAccessToken() {
        DouyinClientTokenReq req = new DouyinClientTokenReq();
        // client_key / client_secret 从字典 DOUYIN_CONFIG 实时读取(后台可改)
        req.setClientKey(getDictValue(Constants.DOUYIN_CLIENT_KEY));
        req.setClientSecret(getDictValue(Constants.DOUYIN_CLIENT_SECRET));
        req.setGrantType("client_credential");
        try {
            Http.HttpWrap wrap = new Http().build(douyinProperties.getHost() + URL_CLIENT_TOKEN);
            Http.HttpResult result = wrap.setRequestProperty("Content-Type", "application/json")
                    .postJSON(JSONObject.parseObject(JSON.toJSONString(req)));
            DouyinClientTokenResp resp = result.toClass(DouyinClientTokenResp.class);
            if (resp == null || resp.getData() == null
                    || resp.getData().getErrorCode() == null
                    || resp.getData().getErrorCode() != 0
                    || StringUtils.isBlank(resp.getData().getAccessToken())) {
                log.error("抖音 client_token 获取失败:{}", JSON.toJSONString(resp));
                throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(), "抖音 client_token 获取失败");
            }
            String token = resp.getData().getAccessToken();
            long expiresIn = resp.getData().getExpiresIn() == null
                    ? TOKEN_DEFAULT_EXPIRE_SECONDS : resp.getData().getExpiresIn();
            redisTemplate.opsForValue().set(douyinProperties.getRedisTokenKey(), token, expiresIn, TimeUnit.SECONDS);
            return token;
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            log.error("调用抖音 client_token 异常", e);
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),
                    "调用抖音 client_token 异常:" + e.getMessage());
        }
    }
 
    /**
     * client_token 剩余有效期(秒);未缓存返回 0
     */
    public long getTokenTtlSeconds() {
        return redisTemplate.getExpire(douyinProperties.getRedisTokenKey(), TimeUnit.SECONDS);
    }
 
    /**
     * 清空缓存的 access-token。
     * <p>后台修改了 client_key/client_secret 后,旧 token 已失效,不清会被 {@link #getAccessToken()}
     * 继续复用,导致抖音接口报「access-token 无效/过期」;清掉后,下次真正调用抖音接口时
     * 才用新配置换取新 token(比立即 refresh 更稳:新配置若有误,不会在保存瞬间就报错)。
     */
    public void clearAccessToken() {
        redisTemplate.delete(douyinProperties.getRedisTokenKey());
    }
 
    // ============================ 商品线上数据列表 ============================
 
    /**
     * 查询商品线上数据列表(单页),token 失效自动刷新重试
     */
    public DouyinBaseResp<DouyinOnlineQueryResp> onlineQuery(DouyinOnlineQueryReq req) {
        if (StringUtils.isBlank(req.getAccountId())) {
            // account_id 从字典 DOUYIN_CONFIG 实时读取(后台可改)
            req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        }
        if (req.getPoiIds() == null || req.getPoiIds().isEmpty()) {
            // poi_ids 从字典 POI_ID 实时读取(当前单门店,放入单元素列表);
            // 抖音规则:poi_ids 传 0 视为空值(返回商户下全量商品),故字典须配真实门店ID,
            // 非数字或 0 时放弃门店过滤(等价返回全量),避免把脏值当过滤条件发出
            String poiIdStr = getDictValue(Constants.DOUYIN_POI_ID);
            if (StringUtils.isNotBlank(poiIdStr)) {
                try {
                    long poiId = Long.parseLong(poiIdStr.trim());
                    if (poiId > 0) {
                        req.setPoiIds(Collections.singletonList(poiId));
                    }
                } catch (NumberFormatException e) {
                    log.warn("字典 POI_ID 非数字,online/query 忽略门店过滤:{}", poiIdStr);
                }
            }
        }
        // GET 方式:入参拼成 query 参数(仿 prepare);buildQuery 会跳过 null 值
        DouyinBaseResp<DouyinOnlineQueryResp> resp = doGet(URL_PRODUCT_ONLINE_QUERY, buildOnlineQueryParams(req),
                new TypeReference<DouyinBaseResp<DouyinOnlineQueryResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doGet(URL_PRODUCT_ONLINE_QUERY, buildOnlineQueryParams(req),
                    new TypeReference<DouyinBaseResp<DouyinOnlineQueryResp>>() {});
        }
        return resp;
    }
 
    /**
     * 把 online/query 入参拼成 GET 查询参数。
     * <p>覆盖 account_id / product_id / out_id / status / cursor / count / poi_ids;
     * buildQuery 会跳过 null 值;status/count 仅非 null 时放入;poi_ids 数组用逗号分隔传参。
     *
     * @param req 查询商品线上数据列表入参
     * @return GET 查询参数 Map(值可能为 null,由 buildQuery 统一跳过)
     */
    private Map<String, String> buildOnlineQueryParams(DouyinOnlineQueryReq req) {
        Map<String, String> params = new LinkedHashMap<>();
        params.put("account_id", req.getAccountId());
        params.put("product_id", req.getProductId());
        params.put("goods_query_type", "2");
        params.put("out_id", req.getOutId());
        if (req.getStatus() != null) {
            params.put("status", String.valueOf(req.getStatus()));
        }
        params.put("cursor", req.getCursor());
        if (req.getCount() != null) {
            params.put("count", String.valueOf(req.getCount()));
        }
        // poi_ids 为 Array<Int64>,GET 用逗号分隔传参(如 poi_ids=123,456)
        if (req.getPoiIds() != null && !req.getPoiIds().isEmpty()) {
            params.put("poi_ids", StringUtils.join(req.getPoiIds(), ","));
        }
        return params;
    }
 
    // ============================ 查询门店信息 ============================
 
    /**
     * 查询商户下已认领的门店列表(管理端选核销门店用),token 失效自动刷新重试。
     * <p>account_id 固定从字典 DOUYIN_CONFIG 实时读取(后台可改),不接收外部入参。
     *
     * @return 门店信息列表;接口异常抛 {@link BusinessException}
     */
    public DouyinBaseResp<DouyinShopPoiResp> shopPoiQuery() {
        // account_id 从字典 DOUYIN_CONFIG 实时读取(后台可改)
        Map<String, String> params = new LinkedHashMap<>();
        params.put("account_id", getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        // GET 方式:入参拼成 query 参数;buildQuery 会跳过 null 值
        DouyinBaseResp<DouyinShopPoiResp> resp = doGet(URL_SHOP_POI_QUERY, params,
                new TypeReference<DouyinBaseResp<DouyinShopPoiResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doGet(URL_SHOP_POI_QUERY, params,
                    new TypeReference<DouyinBaseResp<DouyinShopPoiResp>>() {});
        }
        return resp;
    }
 
    // ============================ 验券准备 ============================
 
    /**
     * 把扫码短链(或含 object_id 的长链)解析为 encrypted_data
     *
     * @return encrypted_data(object_id),解析失败返回 null
     */
    public String resolveShortLink(String shortUrl) {
        if (StringUtils.isBlank(shortUrl)) {
            return null;
        }
        String input = shortUrl.trim();
        String objectId = extractObjectId(input);
        if (objectId != null) {
            return objectId;
        }
        try {
            HttpURLConnection conn = (HttpURLConnection) new URL(input).openConnection();
            conn.setInstanceFollowRedirects(true);
            conn.setRequestMethod("GET");
            conn.setConnectTimeout(5000);
            conn.setReadTimeout(5000);
            conn.connect();
            String finalUrl = conn.getURL().toString();
            conn.disconnect();
            return extractObjectId(finalUrl);
        } catch (Exception e) {
            log.error("解析抖音短链异常:{}", input, e);
            return null;
        }
    }
 
    /**
     * 从 url 中提取 object_id 参数值(即 encrypted_data)
     *
     * @param url 可能含 object_id=xxx 的链接
     * @return 解码后的 object_id,不含则返回 null
     */
    private String extractObjectId(String url) {
        if (url == null) {
            return null;
        }
        int idx = url.indexOf("object_id=");
        if (idx < 0) {
            return null;
        }
        String tail = url.substring(idx + "object_id=".length());
        int end = tail.indexOf('&');
        String val = end < 0 ? tail : tail.substring(0, end);
        try {
            return URLDecoder.decode(val, "UTF-8");
        } catch (Exception e) {
            return val;
        }
    }
 
    /**
     * 验券准备,token 失效自动刷新重试
     */
    public DouyinBaseResp<DouyinPrepareResp> prepare(DouyinPrepareReq req) {
        if (StringUtils.isBlank(req.getAccountId())) {
            // account_id 从字典 DOUYIN_CONFIG 实时读取(后台可改)
            req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        }
        DouyinBaseResp<DouyinPrepareResp> resp = doGet(URL_PREPARE, buildPrepareQuery(req),
                new TypeReference<DouyinBaseResp<DouyinPrepareResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doGet(URL_PREPARE, buildPrepareQuery(req),
                    new TypeReference<DouyinBaseResp<DouyinPrepareResp>>() {});
        }
        return resp;
    }
 
    /**
     * 把验券准备入参拼成 GET 查询参数(prepare 接口走 GET)
     */
    private Map<String, String> buildPrepareQuery(DouyinPrepareReq req) {
        Map<String, String> params = new LinkedHashMap<>();
        params.put("encrypted_data", req.getEncryptedData());
        params.put("code", req.getCode());
        params.put("poi_id", req.getPoiId());
        params.put("account_id", req.getAccountId());
        if (req.getCanVerify() != null) {
            params.put("can_verify", String.valueOf(req.getCanVerify()));
        }
        return params;
    }
 
    // ============================ 验券 ============================
 
    /**
     * 验券(核销),token 失效自动刷新重试
     */
    public DouyinBaseResp<DouyinVerifyResp> verify(DouyinVerifyReq req) {
        if (StringUtils.isBlank(req.getAccountId())) {
            // account_id 从字典 DOUYIN_CONFIG 实时读取(后台可改)
            req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        }
        DouyinBaseResp<DouyinVerifyResp> resp = doPost(URL_VERIFY, req,
                new TypeReference<DouyinBaseResp<DouyinVerifyResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doPost(URL_VERIFY, req,
                    new TypeReference<DouyinBaseResp<DouyinVerifyResp>>() {});
        }
        return resp;
    }
 
    // ============================ 撤销核销 ============================
 
    /**
     * 撤销核销,token 失效自动刷新重试
     */
    public DouyinBaseResp<DouyinCancelResp> cancel(DouyinCancelReq req) {
        if (StringUtils.isBlank(req.getAccountId())) {
            // account_id 从字典 DOUYIN_CONFIG 实时读取(后台可改)
            req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID));
        }
        DouyinBaseResp<DouyinCancelResp> resp = doPost(URL_CANCEL, req,
                new TypeReference<DouyinBaseResp<DouyinCancelResp>>() {});
        if (tokenInvalid(resp)) {
            refreshAccessToken();
            resp = doPost(URL_CANCEL, req,
                    new TypeReference<DouyinBaseResp<DouyinCancelResp>>() {});
        }
        return resp;
    }
 
    // ============================ 内部工具 ============================
 
    /**
     * 统一 POST 调用:带 access-token 请求头,序列化入参,反序列化 {@link DouyinBaseResp}
     *
     * @param path 接口路径(不含 host)
     * @param req  业务入参对象
     * @param type 响应泛型类型引用
     * @return 抖音通用响应外壳;调用异常统一抛 {@link BusinessException}
     */
    private <T> DouyinBaseResp<T> doPost(String path, Object req, TypeReference<DouyinBaseResp<T>> type) {
        try {
            // 入参日志:只打请求 body,不含 access-token 请求头,避免泄密
            log.info("抖音请求 {} 入参:{}", path, JSON.toJSONString(req));
            Http.HttpWrap wrap = new Http().build(douyinProperties.getHost() + path);
            wrap.setRequestProperty("Content-Type", "application/json");
            wrap.setRequestProperty("access-token", getAccessToken());
            Http.HttpResult result = wrap.postJSON(JSONObject.parseObject(JSON.toJSONString(req)));
            // 响应流只能读一次(toStringResult 读后会关闭底层流),先取出字符串复用,避免二次读取报 "stream is closed"
            String body = result.toStringResult();
            // 出参日志:打印抖音响应体,便于排查返回内容
            log.info("抖音响应 {} 出参:{}", path, body);
            return JSON.parseObject(body, type);
        } catch (Exception e) {
            log.error("调用抖音接口异常:path={}", path, e);
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),
                    "调用抖音接口异常:" + e.getMessage());
        }
    }
 
    /**
     * 统一 GET 调用:带 access-token 请求头,查询参数拼到 url,反序列化 {@link DouyinBaseResp}
     */
    private <T> DouyinBaseResp<T> doGet(String path, Map<String, String> params, TypeReference<DouyinBaseResp<T>> type) {
        String url = douyinProperties.getHost() + path;
        String query = buildQuery(params);
        if (StringUtils.isNotBlank(query)) {
            url = url + "?" + query;
        }
        try {
            // 入参日志:GET 查询参数,不含 access-token 请求头
            log.info("抖音请求 {} 入参:{}", path, params);
            Http.HttpWrap wrap = new Http().build(url);
            wrap.setRequestProperty("Content-Type", "application/json");
            wrap.setRequestProperty("access-token", getAccessToken());
            Http.HttpResult result = wrap.get();
            // 响应流只能读一次(toStringResult 读后会关闭底层流),先取出字符串复用,避免二次读取报 "stream is closed"
            String body = result.toStringResult();
            // 出参日志:打印抖音响应体,便于排查返回内容
            log.info("抖音响应 {} 出参:{}", path, body);
            return JSON.parseObject(body, type);
        } catch (Exception e) {
            log.error("调用抖音接口异常:path={}", path, e);
            throw new BusinessException(ResponseStatus.BAD_REQUEST.getCode(),
                    "调用抖音接口异常:" + e.getMessage());
        }
    }
 
    /**
     * 把参数 Map 拼成 a=1&b=2 形式的查询串(跳过 null 值)
     */
    private String buildQuery(Map<String, String> params) {
        if (params == null || params.isEmpty()) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> e : params.entrySet()) {
            if (e.getValue() == null) {
                continue;
            }
            if (sb.length() > 0) {
                sb.append("&");
            }
            sb.append(e.getKey()).append("=").append(urlEncode(e.getValue()));
        }
        return sb.toString();
    }
 
    /**
     * UTF-8 URL 编码,编码异常时原样返回
     */
    private String urlEncode(String value) {
        try {
            return URLEncoder.encode(value, "UTF-8");
        } catch (Exception e) {
            return value;
        }
    }
 
    /**
     * 判断响应是否为 access-token 无效/过期(命中则由调用方刷新后重试一次)
     */
    private boolean tokenInvalid(DouyinBaseResp<?> resp) {
        if (resp == null || resp.getExtra() == null || resp.getExtra().getErrorCode() == null) {
            return false;
        }
        int code = resp.getExtra().getErrorCode();
        return code == ERR_TOKEN_INVALID || code == ERR_TOKEN_EXPIRED;
    }
}