H5 应用容器与奖池的架构
目录
背景#
合作方 App 的后台调一次 /app/url,拿到一个地址,交给用户的浏览器打开。这个地址原本就是第三方服务商的 H5 页面,平台在浏览器这一跳上没有任何位置:加不了防护,统一不了加载体验,也拿不到加载过程的埋点。容器页(前端代码叫 container shell,下文简称 shell)把这一跳换成平台自己的页面,服务商的 H5 应用装进页面里的 iframe。后来平台要在同一个页面底部挂一条奖池活动条(下文叫 overlay),显示四档奖池的实时数额,让用户报名或退出活动,中奖时弹出通知。
奖池的钱来自抽奖券。用户报名一个活动之后,每一笔订单结算,系统就从用户钱包里再扣一张抽奖券的钱,按比例分进四档奖池,然后对这一笔抽一次奖。订单和钱包都在交易平台,也就是平台里负责下单和钱包的服务。
写这篇时(2026-09-24),容器页已经在生产环境跑,奖池还在 QA;下文的同源拓扑在 QA 上已经就位,生产环境还在迁移。这篇覆盖从打开应用到推送的运行时链路,以及抽奖券从扣款到派奖的钱路径;后台管理、计费、抽奖随机数的审计、各服务商的适配器、埋点管道不在范围里。
整体架构#
浏览器这一侧只有一个用户域名,请求先到 Cloudflare,再按路径分给 Worker 或 ALB。合作方后台到 container-service 是服务端之间的调用,不经过浏览器:
唯一的用户域名"] WK["Worker 路由 /open/*
shell 静态资源"] ALB["ALB IngressGroup
其余路径按前缀分流"] subgraph EKS["EKS"] direction TB CS["container-service
/v1/app /v1/container /v1/overlay"] OB["overlay 静态包
/widget/prize"] PS["prize-service
/v1/prize"] SS["stream-service
/stream/prize"] end PB["合作方后台"] SHELL --> CF OVL --> CF CF --> WK CF --> ALB ALB --> CS ALB --> OB ALB --> PS ALB --> SS PB -->|"/app/url,服务端签名"| CS
钱和推送的路径从交易平台的 Kafka topic 进来,中间都在服务端,最后回到交易平台的接口和用户的浏览器:
不持有数据"] PS["prize-service
唯一的数据 owner"] DB[("MySQL 8 与 Redis 7")] TAPI["交易平台的扣款接口与中奖通知"] SS["stream-service
不存状态"] OVL["浏览器里的 overlay"] TOPIC -->|"batch 消费"| PC PC -->|"内部接口:资格、扣款、payout"| PS PS -->|"扣款"| TAPI PC -->|"中奖通知"| TAPI TAPI -->|"审批结果"| PS PS --- DB PS -->|"outbox 经 intake"| SS SS -->|"SSE"| OVL
组件职责#
有状态的组件只有两个:container-service 背后是 Couchbase 和 MySQL,prize-service 背后是 MySQL 与 Redis。其余四个要么跑在浏览器里,要么只经手数据。
| 组件 | 负责什么 | 状态放在哪 |
|---|---|---|
| container shell | 用 URL 里的入口 token 调一次 resolve,把服务商的 H5 应用装进 iframe,注入 overlay,替 overlay 持有 refresh token | 浏览器 localStorage 缓存服务商地址,不存 overlay 凭证 |
| container-service | /app/url 决定返回容器页地址还是服务商原地址;resolve 一次性兑换;签发、刷新 overlay 凭证 |
Couchbase 存入口 session 和 overlay session,MySQL 存开关和合作方凭证 |
| 奖池 overlay | 奖池条、报名和退出、中奖记录;REST 调 prize-service,SSE 连 stream-service | 无,凭证每次向 shell 要 |
| prize-service | 用户接口和后台活动接口;抽奖券扣款与奖池入账;payout 的生命周期;outbox 和每秒一次的奖池 tick | MySQL 8 是 system of record,Redis 7 放可重建的副本和幂等键 |
| prize-consumer | 消费已结算订单:判断资格、定价、交给 prize-service 扣款;扣款成功才抽奖;中奖后建 payout、通知交易平台 | 无,只有 Kafka 的 offset |
| stream-service | 每个用户会话一条 SSE;建连时验凭证;按房间或个人投递 prize-service 发来的帧 | 无 |
奖池这边不直接连任何合作方。交易平台发布已结算订单的 Kafka topic,提供扣款接口,接收中奖通知,再把合作方的审批结果回传给 prize-service。系统设计文档给的理由是:直连就得给每个合作方发凭证、逐个接入,经交易平台转一手只多一跳。
prize-consumer 不持有数据库,奖池的数据只有 prize-service 一个 owner。consumer 的持久性交给 Kafka,pod 挂了就从未提交的 offset 重放,代价是它的每一次持久写都是一次网络调用。
技术栈与选型理由#
| 组件 | 技术栈(版本取自各仓库) |
|---|---|
| container shell | Cloudflare Workers 的 static assets(wrangler 4),一段不依赖前端框架的 container.js |
| 奖池 overlay | React 19.2、TypeScript 6、Vite 8、Tailwind CSS 4、TanStack Query 5 |
| container-service | Java 21、Spring Boot 3.3.4(Spring MVC)、Couchbase Java SDK、Spring Data JPA 加 MySQL、Spring Kafka、jjwt |
| prize-service | Java 25、Spring Boot 3.5.16、Spring Security、Spring Data JPA 加 Flyway、MySQL 8、Redis 7(Lettuce) |
| prize-consumer | Java 25、Spring Boot 4.1.1、Spring Kafka 的 batch listener |
| stream-service | Java 25、Spring Boot 4.1.1、Spring MVC 的 SseEmitter 跑在 virtual threads 上、Nimbus JOSE |
| 部署 | EKS;用户域名下的服务共用一个 ALB IngressGroup;Cloudflare 代理 DNS;Vault Agent 注入密钥;ArgoCD 同步 manifest;Jenkins 构建镜像 |
容器页这边的两条约束写在它的架构提案里:后端跟现有的 Java 21 运行时保持一致,不为 v1 引入新的服务端栈;入口页是平台自己的静态页面,由 Cloudflare 托管,浏览器流量不直接打到源站(提案里写的是 Cloudflare Pages,实现用的是 Workers 的 static assets)。提案还给 resolve 定了服务端 p95 10 ms 以内的目标,入口 session 放在 Couchbase 里,TTL 5 分钟。
stream-service 在 2026-09-14 定栈时的判断是:virtual threads 不会提高连接数上限,它去掉的是为了达到这个上限而改用 reactive 栈的理由,写循环可以继续用阻塞式的写法。LinkedIn 2016 年做即时消息时也选了 SSE,用 Play 加 Akka,每条连接交给一个 actor,生产环境单节点约 10 万条连接;这里承担同样角色的是跑在 virtual threads 上的阻塞式写循环。连接数上限由 Tomcat 的 maxConnections 决定,这里配成 12000,单实例的目标是一万条连接;这个计数器为什么跟线程模型无关,以及 SSE 的选型和单机容量实测,见 SSE 还是 WebSocket 那篇。
prize-service 里 MySQL 是 system of record,Redis 只放能从 MySQL 重建的副本,外加报名、退出这类写请求的幂等键。系统设计文档对 Redis 的要求是丢了只损失性能,不损失钱。
四个 Java 服务落在三条 Spring Boot 版本线上:container-service 在 3.3,prize-service 在 3.5,另外两个在 4.1。截至 2026-09-24,3.3 线的商业支持已在 2026-06-30 结束,3.5 线的 OSS 支持也在同一天结束。
关键设计决策#
下面四条决策是并列的,分别管浏览器入口、凭证、钱和推送。
浏览器只认一个源#
overlay 跟容器页跑在同一个 JS 上下文里,中间没有 iframe 隔开(为什么这么选,见同一页面挂多个 UI 叠加层那篇),它发出的请求都带着容器页的 Origin。奖池的接口、推送和 overlay 静态包原先放在另一个域名上,每个带凭证的请求都要先过一次 CORS preflight,推送用的 EventSource 同样受 CORS 约束。Vite 还会在构建时把 import.meta.env 静态替换掉,写进前端包的绝对地址换一次域名,就得把每个前端重新构建一遍,而容器页的提案要求域名能在不重新部署的情况下切换。
现在只有一个用户域名,由 Cloudflare 代理。Worker 路由只挂 /open/*,返回 shell 的静态资源;其余路径回源到一个 ALB,四个服务的 Ingress 通过同一个 IngressGroup 合并到这个 ALB 上,按路径前缀分流。Cloudflare 的文档把 Worker 路由描述成挂在已代理域名上、位于源站前面的一层,所以 Worker 和 ALB 能共用一个域名。overlay 全部改用相对路径,preflight 没有了,前端包里也不再出现域名。
同源去掉的是 CORS,Origin 白名单还在:prize-service 对非 GET 请求做 CSRF 检查,浏览器在同源的 POST 上也会带 Origin,所以每个环境仍要配 origin 列表。域名也从代码挪进了配置,散在 ingress、Worker 路由、入口链接、Origin 白名单和 CSP 几处,换域名要一起改,其中 CSP 在 shell 部署时渲染,shell 还得重新部署一次。路径前缀则成了四个仓库加一个 Worker 共用的命名空间。
Discord Activities 走的也是一个源加路径映射:Activity 跑在 Discord 的 iframe 里,CSP 只放行 {clientId}.discordsays.com 这个代理域名,访问外部服务要在开发者后台配 URL Mappings,一条路径前缀对应一个目标地址。那里的映射表在一处集中声明,这里的前缀分散在四份 Ingress 和一条 Worker 路由里,没有一个地方能一眼看全,前缀会不会重叠只能靠各仓库自己约定。
container-service 签发凭证,奖池的两个服务本地验签#
奖池这边要知道每个请求、每条推送连接背后是哪个合作方的哪个用户、用什么币种。这些信息只在打开应用那一刻的 container-service 手里,而推送连接会成批重连,按连接回头去问,重连风暴就原样转到了被问的那个服务上。所以 container-service 在 resolve 时签发一张给奖池用的凭证:
入口 token 是一张用合作方 API secret 签名、5 分钟有效的 JWT,resolve 时靠 Couchbase 的 CAS 保证只兑换成功一次。overlay 凭证是一对 token:ES256 签名的 access token 3 分钟有效;refresh token 12 小时有效,每次刷新都轮换,拿已经轮换掉的旧 token 来刷新会被判定为重用,整串作废。这是 RFC 9700 §4.14.2 描述的 refresh token rotation,代价是服务器分不清交旧 token 的是攻击者还是合法客户端,合法客户端也得重新拿授权,放到这里就是用户要从合作方 App 重新打开一次应用。overlay 手里只有一个 getToken 回调,不经手 refresh token;prize-service 和 stream-service 都用公钥在本地验签,不调用任何服务,也签发不了凭证。
Telegram Mini Apps 的凭证是同一个组合:initData 用 bot token 派生的密钥做 HMAC,Bot API 8.0 起又附上 Ed25519 签名,第三方拿 Telegram 公布的公钥就能验,不需要 bot token。Shopify 给嵌入式应用发的 ID token 则和 getToken 是同一种接口:App Bridge 签发 1 分钟过期的 JWT,应用每次请求前调 shopify.idToken() 取新的,不同的是 Shopify 用每个应用自己的 client secret 按 HS256 验签,验签方同时也能签发。
撤销一个用户的凭证,在 SSE 上要等到这个用户下一次建连才生效:推送服务只在建连时验一次,按过期时间切断连接,等于按计时器给自己制造重连风暴。Centrifugo 给单向连接的补法是 refresh proxy:会话快到期时由服务端回调业务后端,后端判定过期就断开,给出新的到期时间就续期。这样撤销最迟在一个到期周期内生效,回调也按每条连接的到期时间摊开,不会挤在重连那一刻。
按 HTML 标准的 IDL,EventSource 带不了自定义请求头,SSE 的 token 放进了 query string,会出现在沿途的访问日志里。Mercure 规范为此把浏览器端的首选放在 cookie 上,query 参数排在最后,理由就是带 token 的 URL 会进日志;同源之后,这里也有了改用 HttpOnly cookie 的条件。
两个服务各自验签,算法、公钥、issuer、audience、claim 名任何一样对不上,用户就只在一边通过认证;公钥又是配置里的静态值,没有 JWKS,换密钥要几个服务一起改配置、一起重启。按 RFC 7517 发布 JWKS、在 JWT 头里带 kid,验签方就能按 key ID 取公钥,新旧密钥可以并存一段时间;Centrifugo 这类服务端直接支持配置一个 JWKS 地址。
扣款和派奖都走交易平台的钱包#
奖池这边不持有用户的钱,抽奖券的扣款和奖金的发放都发生在交易平台的钱包上,奖池只发指令、记账。交易平台的扣款接口不能指望它按幂等键识别重复请求,Kafka 在重启和 rebalance 时又会把整批消息重投一遍,两者叠在一起,一次普通的滚动重启就可能把在途的抽奖券扣两次。
触发点是交易平台的已结算订单 topic,所以用户这一笔订单在奖池开始干活之前就已经完成,用户等待的东西里没有一样依赖奖池。prize-consumer 负责筛消息、定价和抽奖,扣款交给 prize-service:先把抽奖券这一行改成 DISPATCHING 并提交,再在事务之外调用交易平台,最后在一个事务里写回结果,扣款成功的同时给奖池入账。同一笔再来一次,直接从这一行回答,不再问交易平台,代码注释把这一行称作防止第二次扣款的唯一保护。
抽中之后,prize-service 建一条 payout,同时把这笔奖金从奖池里扣掉,再由 consumer 通知交易平台。合作方在 72 小时内审批:批准,钱由交易平台打给用户;拒绝或者超时,奖金退回奖池。
UNRESOLVED 是设计里的一种正常状态:钱可能扣了,也可能没扣,系统自己判断不了,要有人去核对交易平台的记录再结案。抽奖券的扣款也晚于主订单,两者之间用户余额可能已经变了。
Brandur Leach 那篇在 Postgres 里实现 Stripe 式幂等键的文章,把调用外部系统叫 foreign state mutation,调用之前先落一个 recovery point,请求卡住时由后台的 completer 接着往前推,DISPATCHING 就是这样一个检查点。那套能放心重试,是因为 Stripe 按 Idempotency-Key 保存第一次请求的结果,重试时原样返回;交易平台不按幂等键识别重复请求,这里只能停在 UNRESOLVED 等人判断。交易平台哪天能按抽奖券 ID 识别重复请求,后台任务就能像 completer 一样自己把这些行结掉。
推送层只转发、不存状态#
overlay 上的奖池数额 3 秒没有更新就标成过期;中奖要趁用户还在会话里通知到,但在落库之前一个字都不能播出去。推送连接数跟着在线用户数走,奖池的 tick 跟着活动数走。
prize-service 用 transactional outbox 把帧交给 stream-service 的 intake 接口,模式本身见事件驱动架构那篇:一个事实和通知它的义务在同一个事务里提交。奖池的 tick 每秒一次,挂在一个 lease 下面,只有拿到 lease 的 pod 发。stream-service 每个会话一条 SSE,建连时验凭证,帧只按房间(一个活动下的所有会话)或个人投递,内容它一律不读,加新消息不用改它。它不存任何东西,也不发 id: 行,浏览器重连时不会带 Last-Event-ID,它也不补发。这是它和 Mercure 这类推送 hub 最大的差别,Mercure 同样由发布方把消息 POST 给 hub、hub 按订阅投递,但规范要求 hub 尽量按 Last-Event-ID 把错过的事件补发回来。overlay 自己接管重连,重新连上之后先拉一遍当前状态。推送服务的决策记录里有一句 a frame is not a fact:帧只是提示,钱的记录在 prize-service,overlay 拉取时看到的才是真实状态。
断线期间发出的帧会丢,正确性全靠重连之后那一次拉取;延迟上多了 outbox 轮询和一次 intake 调用两跳。扩容也还没解决:多实例之间用 Redis pub/sub 还是 Kafka 做 relay,决策记录里推迟到试点(pilot)之后再定,在那之前 stream-service 只能跑一个副本,加第二个副本,一条房间帧只会到达跟 intake 请求落在同一个实例上的那部分会话。
公开的多实例做法里,Shopify 的 BFCM Live Map 让每台 SSE 服务器直接订阅 Kafka topic,推给自己身上的所有客户端,节点各收全量;Slack 让 channel server 按一致性哈希分管 channel,只把消息发给订阅了这个 channel 的 gateway server,节点只收自己要的。Centrifugo 这类推送服务则把节点间的分发交给 Redis PUB/SUB,历史也存在 Redis 里,断线后可以补发。房间帧适合 Shopify 那种全量广播,个人帧多到广播撑不住,才需要 Slack 那种定向投递。
小结#
用户那一侧现在是一个域名、一条 SSE、一张 3 分钟的凭证;服务端有状态的只有 container-service 和 prize-service;钱路径上的每一步都先在自己的库里落一行,再去碰交易平台。已经能看到的欠账有四笔:生产环境的同源迁移还没做完;推送层没有多实例 relay,只能单副本;签名公钥是配置里的静态值,没有轮换机制;四个 Java 服务分在三条 Spring Boot 版本线上,其中两条已经过了 OSS 支持期。UNRESOLVED 要人工结案是设计上接受的代价,不算欠账,但它决定了奖池上线后后台得有人值守。
参考资料#
- Routes(Cloudflare Workers 文档,路由挂在已代理域名上、位于源站前面)
- Server-sent events(HTML 标准,
EventSource构造函数的 IDL) - RFC 9700: Best Current Practice for OAuth 2.0 Security(§4.14.2,refresh token rotation)
- Env Variables and Modes(Vite 文档,
import.meta.env在构建时静态替换) - Origin(MDN,同源的 POST、PUT、PATCH、DELETE 也带 Origin,GET、HEAD 不带)
- Spring Boot Support(Spring Boot 各版本线 OSS 与商业支持的截止日期)
- ID tokens(Shopify 文档,嵌入式应用的 1 分钟 JWT、
shopify.idToken()、HS256 验签) - Telegram Mini Apps(
initData的 HMAC 校验与 Bot API 8.0 的 Ed25519 第三方校验) - Networking(Discord Activities 文档,
discordsays.com代理与 URL Mappings) - Mercure: The Specification(授权方式的优先顺序、按
Last-Event-ID补发) - Client JWT authentication(Centrifugo 文档,JWKS 验签)
- Proxy events to the backend(Centrifugo 文档,refresh proxy)
- Engines and scalability(Centrifugo 文档,Redis PUB/SUB 与历史补发)
- Using Server Sent Events to Simplify Real-time Streaming at Scale(Shopify Engineering,2022,BFCM Live Map)
- Real-time Messaging(Slack Engineering,2023,channel server 与 gateway server)
- Instant Messaging at LinkedIn: Scaling to Hundreds of Thousands of Persistent Connections on One Machine(LinkedIn Engineering,2016)
- Implementing Stripe-like Idempotency Keys in Postgres(brandur.org,2017,recovery point 与 completer)
- Idempotent requests(Stripe API 文档)
- RFC 7517: JSON Web Key (JWK)(JWK Set 格式)