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;
|
}
|
}
|