ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot接入巨量广告API实践:OAuth授权与Token管理全解析

Spring Boot接入巨量广告API实践:OAuth授权与Token管理全解析 最近正好在做公司内部的投放中台其中一个环节就是在 Spring Boot 后台里接入巨量广告推广平台把巨量那边的广告计划、账户信息、投放报表同步到我们自己的业务系统里。整个过程从申请开发者应用、拿授权到调 API 建计划、接回调花了两周左右时间中间埋了不少坑。今天把项目里能直接落地的方案抽出来重点讲链路怎么设计、代码怎么组织、哪些细节必须注意希望能帮同样在做 Spring Boot 接入巨量广告 API 的朋友少走一些弯路。文章默认你已经会建 Spring Boot 工程、知道 Maven/Gradle 的基本用法但不需要你熟悉巨量广告开放平台的接口体系我会从授权流程开始讲清楚。代码示例不是网上那种只截一段 Controller 的演示代码而是能挂到真实项目里跑的结构你照着改一改基本就能用。1. 接入前必读先搞清楚巨量广告平台的接口逻辑1.1 平台侧的角色关系巨量广告后台里最核心的概念是“广告主”所有广告数据、计划、报表都是挂在广告主账户下的。你的系统要去调 API必须先在巨量开放平台创建一个开发者应用审核通过后拿到 App ID 和 App Secret。一个开发者应用可以对接多个广告主但广告主本身也要在应用下完成授权。这个关系很容易搞混。我第一次接入时就以为拿到 App Secret 就能直接拉所有账户的数据实际上不是。App ID/Secret 只能证明“你是这个应用”而能不能操作某个广告主的数据取决于广告主是否授权了你的应用。也就是说权限模型分了两层应用身份和用户授权。对应到代码里App ID/Secret 用于换取 access_token广告主授权后你才能拿到那个广告主维度的数据。1.2 API 的调用方式和统一返回结构巨量广告开放平台的 API 走的是标准 REST 风格正式环境域名是https://ad.oceanengine.com/open_api/请求参数一般用 JSON响应的外层结构是固定包了一层code和messagecode为 0 表示业务成功非 0 就是业务失败。千万不要只看 HTTP 状态码很多业务错误 HTTP 还是 200。接口按业务用途分成了授权接口、广告账户管理、广告计划管理、广告组、广告创意、素材上传、报表、资产等好几大类每个大类下又有细分接口。巨量接口还有版本概念比如/open_api/2/campaign/create/里的2就是版本号不同版本接口路径和字段会有差异。所以写代码时最好把 API 版本也抽成配置项后面平台升级时你只需要切换配置而不是全局替换代码。2. 整体设计Spring Boot 接入的架构思路2.1 核心设计点Token 不裸露、请求统一封装接入第三方平台的通用痛点就是鉴权和参数处理。巨量广告每个业务接口都要求在 HTTP Header 里带上Access-Token如果每个 Service 里都写一遍取 token、放 header 的代码那项目很快就会被重复代码淹没。我在工程里做了两层抽象。底层是一个OceanEngineClient它负责发 HTTP 请求、自动注入 Header、解析响应、把非 0 的 code 转成业务异常。上层是一个TokenManager只负责一件事给你一个有效的 access_token过期自动刷新刷新时防止并发风暴。这两层之间用接口隔离业务代码根本感知不到 token 的存在。后面如果要换 feather 或换个鉴权方案改动的范围也约束在很小的模块里。这里还要说一句token 一定不能写死在前端配置或者直接放在数据库明文表里。它是广告主的投放凭证泄露了等于别人能操作你的广告账户。项目里我对 token 做了脱敏日志Redis 里的 key 也按广告主维度隔离避免一个广告主刷新 token 把其他人带崩。2.2 技术选型Spring Boot 版本和 HTTP 客户端我用的 Spring Boot 是 2.7.18这个版本在 Java 8/11 生态里很稳定。如果你的项目已经切到 Spring Boot 3也没问题javax 换成 jakarta 就是代码逻辑完全一致。HTTP 客户端我选了 RestTemplate没有用 Feign。原因是巨量广告接口的 URL 很多是动态拼接的查询参数也比较复杂Feign 的注解约束在这种场景下反而别扭。RestTemplate 的优点是轻量、直接配合拦截器或者封装好的 execute 方法可以很自然地把 header 注入、错误处理这些东西收拢到一起。如果你公司内部已经统一用 WebClient那也可以思路一样核心就是那层统一封装。序列化用的 Fastjson2主要是看中它对动态字段的处理更宽松。巨量接口有些字段类型在不同场景下会变比如某个字段正常返回字符串特殊条件下返回数字用强类型 Jackson 去反序列化容易直接报错Fastjson2 容错性更好。但这不是绝对的你也可以用 Jackson只要在实体上配置好忽略未知字段即可。2.3 项目目录结构我习惯把接入第三方平台的代码单独放在一个ocean包下一眼就能看到哪些代码跟巨量相关以后不要了直接删掉整个包也不影响主业务。com.example.yourproject ├── ocean │ ├── config │ │ ├── OceanProperties.java │ │ └── RestTemplateConfig.java │ ├── client │ │ ├── OceanOAuthClient.java │ │ ├── OceanEngineClient.java │ │ └── TokenManager.java │ ├── dto │ │ ├── OAuthTokenResult.java │ │ ├── CampaignCreateRequest.java │ │ ├── CampaignQueryRequest.java │ │ └── OceanResponse.java │ ├── enums │ │ ├── CampaignStatusEnum.java │ │ └── TimeGranularityEnum.java │ ├── callback │ │ └── OceanCallbackController.java │ └── service │ ├── AdvertiserService.java │ ├── CampaignService.java │ └── ReportSyncService.java这个结构的核心要领就是dto 层只放跟巨量接口字段一一对账的入参出参service 层做业务编排client 层做 HTTP 通信。不要把巨量返回的 JSON 直接当成业务对象到处传否则后面联调时一个字段名调整你就得全局搜代码。3. 核心代码实现OAuth 授权与 Token 生命周期3.1 开放平台配置与授权链接写代码之前先把资质和配置搞定。去巨量引擎开放平台注册开发者完成企业认证然后创建应用填写回调域名。这里有个容易忽略的点回调地址必须和你线上实际使用的域名一致本机联调时可以用内网穿透临时顶一下但正式环境中不能随便改改一次要重新触发审核。应用创建好后在后台申请接口权限。权限是按接口粒度开的比如“广告计划只读”“广告计划管理”“数据报表”“账户余额”这些都要逐个申请。刚开始拿一个测试广告主授权即可不要在第一步就急着关联生产广告主很多字段和参数你还没调通容易把线上数据搞乱。拿到授权链接后让广告主在浏览器里打开点击同意授权平台会重定向到你的回调地址并带上auth_code。这个 auth_code 有效期很短一般几分钟拿到后要立即换 token。3.2 通过 auth_code 换取 access_token先定义配置类把应用信息收拢到配置文件里。Data Component ConfigurationProperties(prefix ocean) public class OceanProperties { private String appId; private String secret; private String callbackUrl; private String baseUrl https://ad.oceanengine.com/open_api; private String apiVersion 2; }对应 application.ymlocean: app-id: your-app-id secret: your-app-secret callback-url: https://api.example.com/callback/ocean base-url: https://ad.oceanengine.com/open_api api-version: 2然后实现一个 OAuthClient专门负责调授权接口。Service public class OceanOAuthClient { Autowired private RestTemplate restTemplate; Autowired private OceanProperties oceanProperties; public OAuthTokenResult fetchToken(String authCode) { String url oceanProperties.getBaseUrl() /oauth2/access_token/; MapString, String body new HashMap(); body.put(app_id, oceanProperties.getAppId()); body.put(secret, oceanProperties.getSecret()); body.put(grant_type, auth_code); body.put(auth_code, authCode); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntityMapString, String entity new HttpEntity(body, headers); ResponseEntityOceanResponseOAuthTokenResult response restTemplate.exchange(url, HttpMethod.POST, entity, new ParameterizedTypeReferenceOceanResponseOAuthTokenResult() {}); OceanResponseOAuthTokenResult resp response.getBody(); if (resp null || resp.getCode() ! 0) { throw new BizException(巨量授权换取token失败 (resp null ? empty : resp.getMessage())); } return resp.getData(); } }注意这里的OceanResponse不是巨量 sdk 里的类是我自己封装的统一响应结构体里面只有code、message、data三个字段。小而干净。实际开发中别把巨量返回的一整坨 JSON 直接塞到 Controller 返回给前端内部接口批量对接时统一响应结构可以省掉大量重复剥壳代码。3.3 Token 刷新单飞加锁不把巨量接口打爆access_token 的默认有效期比较短但是 refresh_token 的有效期长很多。所以令牌管理不能只存一个 access_token必须同时保存 refresh_token并且要在 access_token 过期前主动刷新。我在 Redis 里维护了一个 key格式是ocean:token:{advertiserId}value 存的是完整 token 对象 JSON里面包含 access_token、refresh_token、过期时间。这里最关键的一点是防止并发刷新。假如同一时刻有 10 个请求发现 token 过期如果每个请求都去调刷新接口巨量那边很容易返回限流错误而且刷新后的 token 会互相覆盖导致一个失效。我用的方案是 Redis 分布式锁拿到锁的线程才去调刷新接口其他线程拿不到锁就短暂等待后重新读缓存。Component public class TokenManager { Autowired private StringRedisTemplate redisTemplate; Autowired private OceanOAuthClient oceanOAuthClient; Autowired private ObjectMapper objectMapper; private static final String TOKEN_PREFIX ocean:token:; public String getAccessToken(Long advertiserId) { String key TOKEN_PREFIX advertiserId; String cached redisTemplate.opsForValue().get(key); if (StringUtils.hasText(cached)) { OAuthTokenResult tokenResult parseToken(cached); if (tokenResult.getAccessToken() ! null tokenResult.getExpireAt() System.currentTimeMillis() 60_000L) { return tokenResult.getAccessToken(); } } return refreshToken(advertiserId); } public String refreshToken(Long advertiserId) { String lockKey TOKEN_PREFIX lock: advertiserId; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { return waitAndRetry(advertiserId); } try { OAuthTokenResult tokenResult oceanOAuthClient.refreshToken(advertiserId); tokenResult.setExpireAt(System.currentTimeMillis() tokenResult.getExpiresIn() * 1000L); redisTemplate.opsForValue().set(TOKEN_PREFIX advertiserId, objectMapper.writeValueAsString(tokenResult), Duration.ofSeconds(tokenResult.getRefreshTokenExpiresIn())); return tokenResult.getAccessToken(); } catch (Exception e) { throw new BizException(巨量token刷新失败, e); } finally { redisTemplate.delete(lockKey); } } }这段代码我给的是核心骨架你在自己项目里用的时候要注意 token JSON 的序列化和反序列化用同一个 ObjectMapper避免字段名对不上。另外 refresh_token 过期以后Redis 里那条过期时间要设置成 refresh_token 的有效期长度而不是 access_token 的长度。3.4 统一请求头注入与响应解析TokenManager 准备好之后OceanEngineClient 就是整个接入的门面。它负责拼接 URL、注入 access_token、发请求、解析统一响应包装。这几个步骤里最容易出问题的是“读 token”这个动作一定要在每次请求真正发出前才读取不能把 token 作为成员变量缓存否则 token 在后台被过期刷掉后你手里还拿的是旧值。Service public class OceanEngineClient { Autowired private RestTemplate restTemplate; Autowired private OceanProperties oceanProperties; Autowired private TokenManager tokenManager; public T T get(String path, Long advertiserId, TypeReferenceT resultType) { String url oceanProperties.getBaseUrl() / oceanProperties.getApiVersion() path; String token tokenManager.getAccessToken(advertiserId); HttpHeaders headers new HttpHeaders(); headers.set(Access-Token, token); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntityVoid entity new HttpEntity(headers); String raw restTemplate.exchange(url, HttpMethod.GET, entity, String.class).getBody(); OceanResponseT resp JSON.parseObject(raw, new TypeReferenceOceanResponseT() {}); if (resp null || resp.getCode() ! 0) { throw new OceanApiException(resp null ? -1 : resp.getCode(), resp null ? empty response : resp.getMessage()); } return resp.getData(); } }Header 里那个Access-Token的名字在你接入的时候要以官方文档为准不同版本的文档偶尔会有差异。还有一点RestTemplate 默认对 400/500 的响应会直接抛异常所以最好把错误处理也统一做了别让异常散落在每个业务 Service 里。4. 业务功能落地广告账户、广告计划与报表同步4.1 拉取广告账户列表拿到 token 后的第一件事通常是拉取当前应用授权下的广告主账户列表。巨量开放平台有专门的查询接口返回该应用下所有已授权的广告主 ID、名称、状态等信息。很多新手上来就想着直接查计划发现没有 advertiser_id 根本没法调所以要先把账户列表同步到本地。我这边实现了一个 AdvertiserService第一次启动时通过定时任务全量拉取一次账户列表存到本地的 advertiser 表里。以后每天凌晨再增量刷新一次刷新时只比对状态字段不简单删除重建因为账户 ID 在业务系统里可能已经被报表或者其他模块引用了。额外说一句如果将来你的应用要接多个巨量账号一定要给账户列表加上“授权状态”字段。有些广告主可能中途取消授权如果不感知这个状态变化定时同步报表时一直拿旧 token 去调接口会被平台限流。4.2 广告计划查询与创建广告计划在巨量体系里是分层的。广告主账号下面有广告组广告组下面有广告计划计划下面还有创意素材。不同接口操作的层级不同创建顺序一般先从广告组开始再建计划最后传素材。这里我给出查询广告计划的请求封装。查询接口的关键参数是 advertiser_id、过滤条件、分页页码和每页条数。巨量接口的分页比较有特点页码从 1 开始每页条数上限看版本。查询时不要一次性拉全量一是有性能风险二是返回结果量大后本地解析容易堆内存最好的方式是按天或按状态分批拉。public ListCampaignInfo queryCampaigns(Long advertiserId, Integer page, Integer pageSize) { OceanQueryRequest req new OceanQueryRequest(); req.setAdvertiserId(advertiserId); req.setPage(page); req.setPageSize(pageSize); CampaignListResult result oceanEngineClient.get( /campaign/get/, advertiserId, new TypeReferenceOceanResponseCampaignListResult() {} ).getData(); return result.getList(); }创建广告计划时预算和出价字段的单位要特别小心。巨量大部分金额字段的单位是“分”不是“元”。我在联调时把“1000”当成 1000 元传过去结果创建出来的计划预算是 10 元差点闹笑话。还有日期格式很多接口要求传入毫秒级时间戳少传 3 个零直接变 1970 年。创建类的接口入参字段多且必填项分散写代码前最好先对着文档建一份字段检查清单。4.3 报表数据的定时同步报表是投放系统里最常用的数据模块。巨量报表接口支持按时间范围查找但单次时间跨度一般不能超过 31 天所以如果你要同步半年数据必须按天循环拉取。另外报表接口返回的数据量通常不小建议走异步任务加本地任务表的方式来同步不要用同步的 Scheduled 直接刷一天的数据。我在项目里做了一个简单的报表同步状态表字段包括同步日期、广告主 ID、开始时间、结束时间、同步状态、错误信息。定时任务每 5 分钟捞一次待同步任务把任务丢给线程池执行。线程池的线程数不要设置太高巨量接口有 QPS 限制一般单广告主并发 5 以内就够了。还有一点是数据去重。报表接口在平台侧统计可能有延迟同一个时间段的报表你在不同时间点拉到的结果可能不一样。所以落到本地表之前一定要按“广告主 ID 时间维度 计划 ID”做唯一键用 insert or update 的方式写库防止重复数据把汇总指标算两遍。4.4 回调通知处理广告计划审核结果、素材审核状态这些官方支持回调解耦。回调地址就是你在开放平台后台配的那个 URL。回调这块很多人容易忽略验签直接把平台 POST 过来的数据入库这很危险因为回调地址是公网可访问的任何人都可能伪造请求。我这边做了两层校验。第一层验签根据官方文档里的加签规则把请求参数拼接后加上 App Secret 做摘要比对验证通过才继续处理第二层幂等回调内容里一般会有事件编号我在数据库里给事件编号建了唯一索引同一个事件重复回调时直接跳过避免重复处理导致广告计划状态被覆盖。PostMapping(/callback/ocean) public ResponseEntityString handleCallback(RequestBody MapString, Object payload) { if (!signService.verify(payload)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(invalid sign); } String eventId String.valueOf(payload.get(event_id)); if (!callbackRecordService.tryLock(eventId)) { return ResponseEntity.ok(ok); } oceanEventDispatcher.dispatch(payload); return ResponseEntity.ok(ok); }注意回调处理中千万不要同步调外部慢操作比如发短信、触发大量写库。正确做法是先确认收到落库再把事件丢到异步队列里消费。外部系统不会关心你处理成功还是失败它只关心你有没有尽快返回 2xx超时它就会按自己的重试策略转发。5. 常见问题与避坑经验5.1 身份认证和权限类异常排查我在排查问题的过程中总结了一个快速定位表。这套表不一定覆盖全部场景但大部分 4xx 错误都能从这里找到方向。错误码/现象常见原因处理办法401 Unauthorizedtoken 无效/已过期检查是否使用最新 token确认 refresh_token 是否过期403 Forbidden应用无该接口权限去开放平台后台检查接口权限是否申请code40002参数缺失或格式不对对照文档核对字段注意金额单位和时间戳格式code40005广告主没有授权重新走广告主授权流程请求限流QPS 超出限制增加本地重试降低并发数5.2 三个特别容易踩的细节第一个是金额字段单位。巨量广告平台绝大多数金额类字段以分为单位但报表里的统计字段比如消耗、余额单位可能又不一样。最稳妥的做法是用一个 MoneyUtil 工具类统一做分到元的转换不要散落在每个实体里否则出 bug 时很难定位。第二个是时间类型。巨量接口有些字段是秒级时间戳有些是毫秒级时间戳日期参数还会要求具体时区。我在同步报表的时候就吃到过亏把毫秒级时间戳当成秒级传过去结果拉到的数据只有 1970 年附近那几天。写代码你可以在 DTO 的 setter 里统一转换或者在字段上加自定义注解处理。第三个是分页机制不统一。查询列表类接口巨量很多都是 page 从 1 开始这个和有些从 0 开始的外部平台不一样。循环拉取数据的时候判断是否还有下一页要看返回里是否有 total 字段或者当前页数据是否小于 pageSize而不是简单用page * pageSize total判断因为接口对最大翻页深度也有约束拉得越深越容易被限流。5.3 限流和重试策略巨量广告开放平台的 QPS 限制比想象中严格尤其是报表接口和计划查询接口短时间高频调用很容易被返回限流错误码。重试不是无脑重试要有退避策略。我用的方案是第一轮失败后等 200ms 重试第二轮等 500ms第三轮等待 1 秒三次都不成功就直接记录失败信息到任务表等下一轮定时任务再处理。还有一个很多人都忽略的点就是对批处理任务做并发保护。如果你部署了多实例而且每台机器都跑同一个定时任务报表同步就会重复执行。建议用分布式锁把同步任务锁住保证同一时刻只有一台机器在拉数据。5.4 本地事务和外部 API 的不一致问题这是所有对接外部系统的通病本地数据库事务还没提交外部接口已经返回成功或者本地事务提交了外部接口调用失败。我在创建广告计划的场景里遇到过本地保存计划草稿成功了但巨量那边创建失败导致业务状态不对。我的处理原则是绝不把外部 API 调用放到本地事务里。正确姿势是先落本地消息表状态标记为待发送事务提交后异步从消息表捞数据调巨量接口调用成功后更新消息表状态调用失败则记录错误并定时重试。这样做天然支持幂等也保证了最终一致性。代码上多了一张表但换来的是异常数据少了一大截。6. 几句掏心窝的实操体会如果让我重新做一次接入我会先只接一个广告主、一种业务场景比如只做广告计划查询和报表同步把链路跑通跑稳再扩展创建类接口和回调。刚开始就把全部接口接一遍联调时的问题会叠加排查起来很痛苦。日志方面也建议早点规范起来。token 和 secret 绝不能打印到日志但接口的请求路径、响应 code、耗时这些务必记录。出了问题你至少能知道是巨量返回异常还是自己本地解析异常。我还会在每个关键节点加 traceId把本地请求和巨量那边的调用串联起来自查效率会高很多。说到底巨量广告开放平台的对接本身没有太高深的技术难在细节。权限、单位、字段版本、限流、重试这些点每一样都足够让你加班一晚上。希望这篇从设计到落地的实践总结能让你在动手之前就把坑都看清后面接起来顺畅一些。这次就先写到这里后面有时间再把素材上传和创意构建那部分的实现细节单独整理一篇。
返回列表