| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- package com.adx.tencent.vivo.service;
- import com.adx.tencent.vivo.model.VivoAdvertiserAccountRecord;
- import com.adx.tencent.vivo.model.VivoClientConfigRecord;
- import com.adx.tencent.vivo.model.VivoTokenRecord;
- import com.adx.tencent.vivo.store.VivoColdStore;
- import com.fasterxml.jackson.databind.JsonNode;
- import com.fasterxml.jackson.databind.ObjectMapper;
- import org.slf4j.Logger;
- import org.slf4j.LoggerFactory;
- import org.springframework.data.redis.core.StringRedisTemplate;
- import java.net.URI;
- import java.net.URLEncoder;
- import java.net.http.HttpClient;
- import java.net.http.HttpRequest;
- import java.net.http.HttpResponse;
- import java.nio.charset.StandardCharsets;
- import java.time.Duration;
- import java.time.Instant;
- import java.util.ArrayList;
- import java.util.LinkedHashMap;
- import java.util.List;
- import java.util.Map;
- import java.util.UUID;
- import java.util.concurrent.ConcurrentHashMap;
- public class VivoAuthService {
- private static final Logger log = LoggerFactory.getLogger(VivoAuthService.class);
- private static final long REFRESH_AHEAD_MS = 60_000L;
- private static final Duration DEFAULT_REDIS_TTL = Duration.ofDays(30);
- private static final String TOKEN_PATH = "/openapi/v1/oauth2/token";
- private static final String REFRESH_PATH = "/openapi/v1/oauth2/refreshToken";
- private static final String AGENT_FETCH_PATH = "/openapi/v1/account/agent/fetch";
- private final String baseUrl;
- private final String redirectUri;
- private final String tokenRedisKeyPrefix;
- private final String advertiserRedisKeyPrefix;
- private final StringRedisTemplate redis;
- private final VivoColdStore coldStore;
- private final ObjectMapper objectMapper;
- private final HttpClient httpClient;
- private final ConcurrentHashMap<String, Object> locks = new ConcurrentHashMap<>();
- public VivoAuthService(String baseUrl,
- String redirectUri,
- String tokenRedisKeyPrefix,
- String advertiserRedisKeyPrefix,
- StringRedisTemplate redis,
- VivoColdStore coldStore,
- ObjectMapper objectMapper) {
- this.baseUrl = trimTrailingSlash(baseUrl);
- this.redirectUri = redirectUri;
- this.tokenRedisKeyPrefix = tokenRedisKeyPrefix == null ? "adx:vivo:token:" : tokenRedisKeyPrefix;
- this.advertiserRedisKeyPrefix = advertiserRedisKeyPrefix == null
- ? "adx:vivo:advertiser:"
- : advertiserRedisKeyPrefix;
- this.redis = redis;
- this.coldStore = coldStore;
- this.objectMapper = objectMapper;
- this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
- }
- public VivoTokenRecord authorizeSecondaryAccount(String secondaryAccountId, String clientId, String code) {
- if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
- throw new IllegalArgumentException("vivo secondaryAccountId is required");
- }
- if (code == null || code.isBlank()) {
- throw new IllegalArgumentException("vivo authorization code is required");
- }
- String expectedSecondaryAccountId = secondaryAccountId.trim();
- ClientCredentials credentials = resolveAuthorizeCredentials(expectedSecondaryAccountId, clientId);
- TokenPayload payload = fetchToken(Map.of(
- "client_id", credentials.clientId,
- "client_secret", credentials.clientSecret,
- "grant_type", "code",
- "code", code
- ), TOKEN_PATH);
- AgentAccountProfile profile = fetchAgentAccountProfile(payload.accessToken);
- if (profile.accountId == null || profile.accountId.isBlank()) {
- throw new IllegalStateException("vivo secondary account uuid is missing");
- }
- if (!expectedSecondaryAccountId.equals(profile.accountId)) {
- throw new IllegalStateException("authorized vivo secondaryAccountId mismatch: expected="
- + expectedSecondaryAccountId + ", actual=" + profile.accountId);
- }
- VivoTokenRecord record = buildTokenRecord(profile, payload, expectedSecondaryAccountId, credentials);
- saveToken(record);
- log.info("[Vivo][授权] secondaryAccountId={} accountName={} level={} status={} tokenExpireAt={} refreshExpireAt={}",
- record.getAccountId(), record.getAccountName(), record.getAccountLevel(), record.getAccountStatus(),
- record.getTokenExpireAt(), record.getRefreshTokenExpireAt());
- return record;
- }
- public String getAccessTokenForAdvertiser(String advertiserId) {
- if (advertiserId == null || advertiserId.isBlank()) {
- throw new IllegalArgumentException("vivo advertiserId is required");
- }
- String normalizedAdvertiserId = advertiserId.trim();
- String secondaryAccountId = resolveSecondaryAccountId(normalizedAdvertiserId);
- if (secondaryAccountId != null && !secondaryAccountId.isBlank()) {
- return getAccessTokenBySecondaryAccountId(secondaryAccountId);
- }
- VivoTokenRecord directAuthorized = loadToken(normalizedAdvertiserId);
- if (directAuthorized != null) {
- log.warn("[Vivo][Token] advertiserId={} 未命中二代映射,退回使用同ID授权 token", normalizedAdvertiserId);
- return getAccessTokenBySecondaryAccountId(normalizedAdvertiserId);
- }
- throw new IllegalStateException("vivo advertiser not mapped to any secondary account: " + normalizedAdvertiserId);
- }
- public String getAccessTokenBySecondaryAccountId(String secondaryAccountId) {
- if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
- throw new IllegalArgumentException("vivo secondaryAccountId is required");
- }
- String normalizedSecondaryAccountId = secondaryAccountId.trim();
- synchronized (locks.computeIfAbsent(normalizedSecondaryAccountId, ignored -> new Object())) {
- VivoTokenRecord record = loadToken(normalizedSecondaryAccountId);
- long now = System.currentTimeMillis();
- if (isUsable(record != null ? record.getAccessToken() : null, record != null ? record.getTokenExpireAt() : null, now)) {
- return record.getAccessToken();
- }
- if (record != null && isUsable(record.getRefreshToken(), record.getRefreshTokenExpireAt(), now)) {
- VivoTokenRecord refreshed = refresh(normalizedSecondaryAccountId, record.getRefreshToken());
- return refreshed.getAccessToken();
- }
- if (record != null && record.getAccessToken() != null && !record.getAccessToken().isBlank()) {
- log.warn("[Vivo][Token] secondaryAccountId={} access_token 已过期且 refresh_token 不可用,暂退回使用旧 token",
- normalizedSecondaryAccountId);
- return record.getAccessToken();
- }
- throw new IllegalStateException("vivo secondary account not authorized: " + normalizedSecondaryAccountId);
- }
- }
- public List<VivoTokenRecord> listAuthorizedSecondaryAccounts() {
- if (coldStore == null) {
- return List.of();
- }
- return coldStore.listTokens();
- }
- public void cacheAdvertiserMappings(List<VivoAdvertiserAccountRecord> records) {
- if (records == null || records.isEmpty()) {
- return;
- }
- for (VivoAdvertiserAccountRecord record : records) {
- cacheAdvertiserMapping(record);
- }
- }
- public void evictAdvertiserMappings(List<String> advertiserIds) {
- if (advertiserIds == null || advertiserIds.isEmpty()) {
- return;
- }
- for (String advertiserId : advertiserIds) {
- if (advertiserId == null || advertiserId.isBlank()) {
- continue;
- }
- try {
- redis.delete(advertiserRedisKey(advertiserId.trim()));
- } catch (Exception e) {
- log.warn("[Vivo][Advertiser] evict redis failed | advertiserId={} | error={}",
- advertiserId, e.getMessage());
- }
- }
- }
- public String buildAuthorizeUrl(String secondaryAccountId, String clientId) {
- String normalizedSecondaryAccountId = secondaryAccountId == null ? "" : secondaryAccountId.trim();
- ClientCredentials credentials = requireClientConfig(clientId);
- return trimTrailingSlash("https://open-ad.vivo.com.cn")
- + "/OAuth?clientId=" + urlEncode(credentials.clientId)
- + "&state=" + urlEncode(normalizedSecondaryAccountId)
- + "&redirectUri=" + urlEncode(redirectUri == null ? "" : redirectUri);
- }
- public String buildAuthorizeState(String secondaryAccountId, String clientId) {
- if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
- throw new IllegalArgumentException("vivo secondaryAccountId is required");
- }
- return secondaryAccountId.trim();
- }
- private String resolveSecondaryAccountId(String advertiserId) {
- String cached = loadSecondaryAccountIdFromRedis(advertiserId);
- if (cached != null) {
- return cached;
- }
- if (coldStore == null) {
- return null;
- }
- VivoAdvertiserAccountRecord record = coldStore.getAdvertiserAccount(advertiserId);
- if (record != null && record.getSecondaryAccountId() != null && !record.getSecondaryAccountId().isBlank()) {
- cacheAdvertiserMapping(record);
- return record.getSecondaryAccountId();
- }
- return null;
- }
- private String loadSecondaryAccountIdFromRedis(String advertiserId) {
- try {
- String payload = redis.opsForValue().get(advertiserRedisKey(advertiserId));
- return payload == null || payload.isBlank() ? null : payload.trim();
- } catch (Exception e) {
- log.warn("[Vivo][Advertiser] read redis failed | advertiserId={} | error={}", advertiserId, e.getMessage());
- return null;
- }
- }
- private void cacheAdvertiserMapping(VivoAdvertiserAccountRecord record) {
- if (record == null || record.getAdvertiserId() == null || record.getAdvertiserId().isBlank()
- || record.getSecondaryAccountId() == null || record.getSecondaryAccountId().isBlank()) {
- return;
- }
- try {
- redis.opsForValue().set(
- advertiserRedisKey(record.getAdvertiserId().trim()),
- record.getSecondaryAccountId().trim(),
- DEFAULT_REDIS_TTL
- );
- } catch (Exception e) {
- log.warn("[Vivo][Advertiser] write redis failed | advertiserId={} | secondaryAccountId={} | error={}",
- record.getAdvertiserId(), record.getSecondaryAccountId(), e.getMessage());
- }
- }
- private VivoTokenRecord refresh(String secondaryAccountId, String refreshToken) {
- ClientCredentials credentials = requireStoredCredentials(secondaryAccountId);
- TokenPayload payload = fetchToken(Map.of(
- "client_id", credentials.clientId,
- "client_secret", credentials.clientSecret,
- "refresh_token", refreshToken
- ), REFRESH_PATH);
- AgentAccountProfile profile = fetchAgentAccountProfile(payload.accessToken);
- if (profile.accountId != null && !profile.accountId.isBlank() && !secondaryAccountId.equals(profile.accountId)) {
- throw new IllegalStateException("refreshed vivo secondaryAccountId mismatch: expected="
- + secondaryAccountId + ", actual=" + profile.accountId);
- }
- VivoTokenRecord existing = coldStore != null ? coldStore.getToken(secondaryAccountId) : null;
- VivoTokenRecord record = buildTokenRecord(profile, payload, secondaryAccountId, credentials);
- record.setAuthorizedAt(existing != null && existing.getAuthorizedAt() != null ? existing.getAuthorizedAt() : Instant.now());
- saveToken(record);
- log.info("[Vivo][Token] secondaryAccountId={} refresh success tokenExpireAt={} refreshExpireAt={}",
- secondaryAccountId, payload.tokenExpireAt, payload.refreshTokenExpireAt);
- return record;
- }
- private VivoTokenRecord buildTokenRecord(AgentAccountProfile profile,
- TokenPayload payload,
- String secondaryAccountId,
- ClientCredentials credentials) {
- VivoTokenRecord record = new VivoTokenRecord();
- record.setAccountId(secondaryAccountId);
- record.setAccountName(profile.accountName);
- record.setAccountLevel(profile.accountLevel);
- record.setAccountStatus(profile.accountStatus);
- record.setClientId(credentials.clientId);
- record.setClientSecret(credentials.clientSecret);
- record.setAccessToken(payload.accessToken);
- record.setRefreshToken(payload.refreshToken);
- record.setTokenExpireAt(payload.tokenExpireAt);
- record.setRefreshTokenExpireAt(payload.refreshTokenExpireAt);
- if (record.getAuthorizedAt() == null) {
- record.setAuthorizedAt(Instant.now());
- }
- record.setUpdatedAt(Instant.now());
- return record;
- }
- private AgentAccountProfile fetchAgentAccountProfile(String accessToken) {
- try {
- long timestamp = System.currentTimeMillis();
- String nonce = randomNonce();
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(baseUrl + AGENT_FETCH_PATH + "?" + toQuery(Map.of(
- "access_token", accessToken,
- "timestamp", String.valueOf(timestamp),
- "nonce", nonce
- ))))
- .GET()
- .timeout(Duration.ofSeconds(15))
- .build();
- HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
- if (response.statusCode() < 200 || response.statusCode() >= 300) {
- throw new IllegalStateException("HTTP status: " + response.statusCode() + ", body=" + response.body());
- }
- JsonNode root = objectMapper.readTree(response.body());
- int code = root.path("code").asInt(Integer.MIN_VALUE);
- if (code != 0) {
- throw new IllegalStateException("code=" + code + ", message=" + root.path("message").asText() + ", body=" + response.body());
- }
- JsonNode data = root.path("data");
- AgentAccountProfile profile = new AgentAccountProfile();
- profile.accountId = text(data, "uuid");
- profile.accountName = text(data, "name");
- profile.accountLevel = asInt(data.get("level"));
- profile.accountStatus = asInt(data.get("status"));
- return profile;
- } catch (Exception e) {
- throw new RuntimeException("fetch vivo secondary account failed: " + e.getMessage(), e);
- }
- }
- private TokenPayload fetchToken(Map<String, String> params, String path) {
- try {
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(baseUrl + path + "?" + toQuery(params)))
- .GET()
- .timeout(Duration.ofSeconds(15))
- .build();
- HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
- if (response.statusCode() < 200 || response.statusCode() >= 300) {
- throw new IllegalStateException("HTTP status: " + response.statusCode() + ", body=" + response.body());
- }
- JsonNode root = objectMapper.readTree(response.body());
- int code = root.path("code").asInt(Integer.MIN_VALUE);
- if (code != 0) {
- throw new IllegalStateException("code=" + code + ", message=" + root.path("message").asText() + ", body=" + response.body());
- }
- JsonNode data = root.path("data");
- TokenPayload payload = new TokenPayload();
- payload.accessToken = text(data, "access_token");
- payload.refreshToken = text(data, "refresh_token");
- payload.tokenExpireAt = asLong(data.get("token_date"));
- payload.refreshTokenExpireAt = asLong(data.get("refresh_token_date"));
- if (payload.accessToken == null || payload.accessToken.isBlank()) {
- throw new IllegalStateException("missing access_token in vivo token response");
- }
- return payload;
- } catch (Exception e) {
- throw new RuntimeException("fetch vivo token failed: " + e.getMessage(), e);
- }
- }
- private VivoTokenRecord loadToken(String secondaryAccountId) {
- VivoTokenRecord cached = loadTokenFromRedis(secondaryAccountId);
- if (cached != null) {
- return cached;
- }
- if (coldStore == null) {
- return null;
- }
- VivoTokenRecord db = coldStore.getToken(secondaryAccountId);
- if (db != null) {
- cacheToken(db);
- }
- return db;
- }
- private VivoTokenRecord loadTokenFromRedis(String secondaryAccountId) {
- try {
- String payload = redis.opsForValue().get(tokenRedisKey(secondaryAccountId));
- if (payload == null || payload.isBlank()) {
- return null;
- }
- return objectMapper.readValue(payload, VivoTokenRecord.class);
- } catch (Exception e) {
- log.warn("[Vivo][Token] read redis failed | secondaryAccountId={} | error={}",
- secondaryAccountId, e.getMessage());
- return null;
- }
- }
- private void saveToken(VivoTokenRecord record) {
- cacheToken(record);
- if (coldStore != null) {
- coldStore.saveToken(record);
- }
- }
- private void cacheToken(VivoTokenRecord record) {
- try {
- redis.opsForValue().set(
- tokenRedisKey(record.getAccountId()),
- objectMapper.writeValueAsString(record),
- resolveRedisTtl(record)
- );
- } catch (Exception e) {
- log.warn("[Vivo][Token] write redis failed | secondaryAccountId={} | error={}",
- record.getAccountId(), e.getMessage());
- }
- }
- private Duration resolveRedisTtl(VivoTokenRecord record) {
- long now = System.currentTimeMillis();
- long expireAt = record.getRefreshTokenExpireAt() != null && record.getRefreshTokenExpireAt() > now
- ? record.getRefreshTokenExpireAt()
- : record.getTokenExpireAt() != null && record.getTokenExpireAt() > now
- ? record.getTokenExpireAt()
- : now + DEFAULT_REDIS_TTL.toMillis();
- long ttlMs = Math.max(expireAt - now, Duration.ofMinutes(5).toMillis());
- return Duration.ofMillis(ttlMs);
- }
- private String tokenRedisKey(String secondaryAccountId) {
- return tokenRedisKeyPrefix + secondaryAccountId;
- }
- private String advertiserRedisKey(String advertiserId) {
- return advertiserRedisKeyPrefix + advertiserId;
- }
- private ClientCredentials requireStoredCredentials(String secondaryAccountId) {
- if (secondaryAccountId == null || secondaryAccountId.isBlank()) {
- throw new IllegalArgumentException("vivo secondaryAccountId is required");
- }
- VivoTokenRecord record = loadToken(secondaryAccountId.trim());
- String clientId = record == null ? null : trimToNull(record.getClientId());
- String clientSecret = record == null ? null : trimToNull(record.getClientSecret());
- if (clientId == null || clientSecret == null) {
- throw new IllegalStateException("vivo clientId/clientSecret not maintained in DB for secondaryAccountId="
- + secondaryAccountId);
- }
- ClientCredentials credentials = new ClientCredentials();
- credentials.clientId = clientId;
- credentials.clientSecret = clientSecret;
- return credentials;
- }
- private ClientCredentials resolveAuthorizeCredentials(String secondaryAccountId, String clientId) {
- String normalizedClientId = trimToNull(clientId);
- if (normalizedClientId != null) {
- return requireClientConfig(normalizedClientId);
- }
- VivoTokenRecord existing = loadToken(secondaryAccountId);
- String storedClientId = existing == null ? null : trimToNull(existing.getClientId());
- String storedClientSecret = existing == null ? null : trimToNull(existing.getClientSecret());
- if (storedClientId != null && storedClientSecret != null) {
- ClientCredentials credentials = new ClientCredentials();
- credentials.clientId = storedClientId;
- credentials.clientSecret = storedClientSecret;
- return credentials;
- }
- List<VivoClientConfigRecord> enabledConfigs = coldStore == null ? List.of() : coldStore.listEnabledClientConfigs();
- if (enabledConfigs.size() == 1) {
- return requireClientConfig(enabledConfigs.get(0).getClientId());
- }
- if (enabledConfigs.isEmpty()) {
- throw new IllegalStateException("no enabled vivo client config found in vivo_client_configs");
- }
- throw new IllegalStateException("multiple enabled vivo client configs found; clientId is required for secondaryAccountId="
- + secondaryAccountId);
- }
- private ClientCredentials requireClientConfig(String clientId) {
- String normalizedClientId = trimToNull(clientId);
- if (normalizedClientId == null) {
- throw new IllegalArgumentException("vivo clientId is required");
- }
- VivoClientConfigRecord record = coldStore == null ? null : coldStore.getClientConfig(normalizedClientId);
- if (record == null) {
- throw new IllegalStateException("vivo clientId not maintained in vivo_client_configs: " + normalizedClientId);
- }
- if (Boolean.FALSE.equals(record.getEnabled())) {
- throw new IllegalStateException("vivo clientId disabled: " + normalizedClientId);
- }
- String clientSecret = trimToNull(record.getClientSecret());
- if (clientSecret == null) {
- throw new IllegalStateException("vivo clientSecret is empty for clientId=" + normalizedClientId);
- }
- ClientCredentials credentials = new ClientCredentials();
- credentials.clientId = normalizedClientId;
- credentials.clientSecret = clientSecret;
- return credentials;
- }
- private static boolean isUsable(String token, Long expireAt, long now) {
- return token != null && !token.isBlank() && expireAt != null && expireAt - now > REFRESH_AHEAD_MS;
- }
- private static String toQuery(Map<String, String> params) {
- StringBuilder sb = new StringBuilder();
- boolean first = true;
- for (Map.Entry<String, String> entry : params.entrySet()) {
- if (entry.getValue() == null || entry.getValue().isBlank()) continue;
- if (!first) sb.append('&');
- first = false;
- sb.append(urlEncode(entry.getKey())).append('=').append(urlEncode(entry.getValue()));
- }
- return sb.toString();
- }
- private static String randomNonce() {
- return UUID.randomUUID().toString().replace("-", "");
- }
- private static String urlEncode(String value) {
- return URLEncoder.encode(value, StandardCharsets.UTF_8);
- }
- private static String text(JsonNode node, String field) {
- JsonNode child = node == null ? null : node.get(field);
- if (child == null || child.isNull()) {
- return null;
- }
- String value = child.asText();
- return value == null || value.isBlank() ? null : value.trim();
- }
- private static Long asLong(JsonNode node) {
- if (node == null || node.isNull()) {
- return null;
- }
- if (node.isNumber()) {
- return node.longValue();
- }
- try {
- return Long.parseLong(node.asText().trim());
- } catch (Exception e) {
- return null;
- }
- }
- private static Integer asInt(JsonNode node) {
- if (node == null || node.isNull()) {
- return null;
- }
- if (node.isInt() || node.isLong()) {
- return node.intValue();
- }
- try {
- return Integer.parseInt(node.asText().trim());
- } catch (Exception e) {
- return null;
- }
- }
- private static String trimToNull(String value) {
- if (value == null) {
- return null;
- }
- String trimmed = value.trim();
- return trimmed.isEmpty() ? null : trimmed;
- }
- private static String trimTrailingSlash(String value) {
- if (value == null || value.isBlank()) {
- return "https://marketing-api.vivo.com.cn";
- }
- String result = value.trim();
- while (result.endsWith("/")) {
- result = result.substring(0, result.length() - 1);
- }
- return result;
- }
- private static class TokenPayload {
- private String accessToken;
- private String refreshToken;
- private Long tokenExpireAt;
- private Long refreshTokenExpireAt;
- }
- private static class AgentAccountProfile {
- private String accountId;
- private String accountName;
- private Integer accountLevel;
- private Integer accountStatus;
- }
- private static class ClientCredentials {
- private String clientId;
- private String clientSecret;
- }
- }
|