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

后台修改了 client_key/client_secret 后,旧 token 已失效,不清会被 {@link #getAccessToken()} * 继续复用,导致抖音接口报「access-token 无效/过期」;清掉后,下次真正调用抖音接口时 * 才用新配置换取新 token(比立即 refresh 更稳:新配置若有误,不会在保存瞬间就报错)。 */ public void clearAccessToken() { redisTemplate.delete(douyinProperties.getRedisTokenKey()); } // ============================ 商品线上数据列表 ============================ /** * 查询商品线上数据列表(单页),token 失效自动刷新重试 */ public DouyinBaseResp 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 resp = doGet(URL_PRODUCT_ONLINE_QUERY, buildOnlineQueryParams(req), new TypeReference>() {}); if (tokenInvalid(resp)) { refreshAccessToken(); resp = doGet(URL_PRODUCT_ONLINE_QUERY, buildOnlineQueryParams(req), new TypeReference>() {}); } return resp; } /** * 把 online/query 入参拼成 GET 查询参数。 *

覆盖 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 buildOnlineQueryParams(DouyinOnlineQueryReq req) { Map 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,GET 用逗号分隔传参(如 poi_ids=123,456) if (req.getPoiIds() != null && !req.getPoiIds().isEmpty()) { params.put("poi_ids", StringUtils.join(req.getPoiIds(), ",")); } return params; } // ============================ 查询门店信息 ============================ /** * 查询商户下已认领的门店列表(管理端选核销门店用),token 失效自动刷新重试。 *

account_id 固定从字典 DOUYIN_CONFIG 实时读取(后台可改),不接收外部入参。 * * @return 门店信息列表;接口异常抛 {@link BusinessException} */ public DouyinBaseResp shopPoiQuery() { // account_id 从字典 DOUYIN_CONFIG 实时读取(后台可改) Map params = new LinkedHashMap<>(); params.put("account_id", getDictValue(Constants.DOUYIN_ACCOUNT_ID)); // GET 方式:入参拼成 query 参数;buildQuery 会跳过 null 值 DouyinBaseResp resp = doGet(URL_SHOP_POI_QUERY, params, new TypeReference>() {}); if (tokenInvalid(resp)) { refreshAccessToken(); resp = doGet(URL_SHOP_POI_QUERY, params, new TypeReference>() {}); } 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 prepare(DouyinPrepareReq req) { if (StringUtils.isBlank(req.getAccountId())) { // account_id 从字典 DOUYIN_CONFIG 实时读取(后台可改) req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID)); } DouyinBaseResp resp = doGet(URL_PREPARE, buildPrepareQuery(req), new TypeReference>() {}); if (tokenInvalid(resp)) { refreshAccessToken(); resp = doGet(URL_PREPARE, buildPrepareQuery(req), new TypeReference>() {}); } return resp; } /** * 把验券准备入参拼成 GET 查询参数(prepare 接口走 GET) */ private Map buildPrepareQuery(DouyinPrepareReq req) { Map 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 verify(DouyinVerifyReq req) { if (StringUtils.isBlank(req.getAccountId())) { // account_id 从字典 DOUYIN_CONFIG 实时读取(后台可改) req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID)); } DouyinBaseResp resp = doPost(URL_VERIFY, req, new TypeReference>() {}); if (tokenInvalid(resp)) { refreshAccessToken(); resp = doPost(URL_VERIFY, req, new TypeReference>() {}); } return resp; } // ============================ 撤销核销 ============================ /** * 撤销核销,token 失效自动刷新重试 */ public DouyinBaseResp cancel(DouyinCancelReq req) { if (StringUtils.isBlank(req.getAccountId())) { // account_id 从字典 DOUYIN_CONFIG 实时读取(后台可改) req.setAccountId(getDictValue(Constants.DOUYIN_ACCOUNT_ID)); } DouyinBaseResp resp = doPost(URL_CANCEL, req, new TypeReference>() {}); if (tokenInvalid(resp)) { refreshAccessToken(); resp = doPost(URL_CANCEL, req, new TypeReference>() {}); } return resp; } // ============================ 内部工具 ============================ /** * 统一 POST 调用:带 access-token 请求头,序列化入参,反序列化 {@link DouyinBaseResp} * * @param path 接口路径(不含 host) * @param req 业务入参对象 * @param type 响应泛型类型引用 * @return 抖音通用响应外壳;调用异常统一抛 {@link BusinessException} */ private DouyinBaseResp doPost(String path, Object req, TypeReference> 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 DouyinBaseResp doGet(String path, Map params, TypeReference> 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 params) { if (params == null || params.isEmpty()) { return ""; } StringBuilder sb = new StringBuilder(); for (Map.Entry 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; } }