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 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 listAuthorizedSecondaryAccounts() { if (coldStore == null) { return List.of(); } return coldStore.listTokens(); } public void cacheAdvertiserMappings(List records) { if (records == null || records.isEmpty()) { return; } for (VivoAdvertiserAccountRecord record : records) { cacheAdvertiserMapping(record); } } public void evictAdvertiserMappings(List 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 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 params, String path) { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(baseUrl + path + "?" + toQuery(params))) .GET() .timeout(Duration.ofSeconds(15)) .build(); HttpResponse 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 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 params) { StringBuilder sb = new StringBuilder(); boolean first = true; for (Map.Entry 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; } }