Go Wind UBA 拆解系列 - 架构总览三服务、数据流与契约优先本文回答一个问题当一个用户行为从浏览器发出到最终在 Vue 看板上变成一条留存曲线中间经过了哪些服务、哪些代码、哪些取舍一、先看全貌四个角色的接力GoWind UBA 不是单体也不是微服务为了微服务。它把用户行为分析的链路切成职责清晰的三段服务外加一个采集 SDKSDK 上报 ──HTTP──▶ Collector ──Publish──▶ Kafka ──▶ Core ──▶ OLAP │ Vue 看板 ◀──HTTP/SSE── Admin ◀──gRPC──┘服务角色端口一句话职责Collector采集 BFFHTTP 5700接收 SDK 上报鉴权 校验 补全转发 Kafka。无状态、不落库Core核心业务gRPC 动态端口事件入库、25 个分析模型、风险检测、标签画像。所有重逻辑Admin管理 BFFHTTP 5600 / SSE 5601给前端用的 HTTP 网关薄转发到 CoreSDK客户端—浏览器TS/ Unity·GodotC#批量上报 重试三个服务都通过etcd注册发现跨服务调用走gRPCCore 的 gRPC 端口是0.0.0.0:0随机端口启动时注册到 etcdAdmin/Collector 凭 etcd 找到它。这套划分的好处很实在Collector 纯 IO可以无脑水平扩Admin 是薄转发改前端需求不动它Core 才是承载业务复杂度的地方。下面逐个拆。二、Collector只干一件事干到极致Collector 的代码量很小因为它恪守接收 转发的边界。入口就一个POST /uba/v1/report。它做的事是一个严格的四步流水线backend/app/collector/service/internal/service/report_service.go1. appId/appSecret 鉴权 → 拿到权威 tenantId 2. validateEvents → 校验 eventId/eventName/eventTime oneof 载荷 3. 权威覆盖 tenantId → 杜绝跨租户伪造 4. handleBehavior/Risk → 补字段 → Publish Kafka几个值得注意的设计鉴权在请求体不在 Header。appId/appSecret放在 JSON body 里。这看起来不标准但它有一个关键收益让sendBeacon成为可能——浏览器关闭页面时navigator.sendBeacon无法设置自定义 Header只能发 body。把凭证放进 bodySDK 在页面卸载时也能可靠地把残留事件冲刷出去详见 第 3 篇。鉴权器是加固过的。AppAuthenticatorapp_auth.go有几个细节值得抠Redis 里只存appSecret的SHA-256 哈希不存明文——Redis 泄露不等于密钥泄露。比较密钥用subtle.ConstantTimeCompare——防时序攻击。负缓存不存在的 appId 也写进 Redis短 TTL 1 分钟防缓存穿透打穿到 DB。gRPC 查应用失败时返回InternalServerError而不是Unauthorized——网络抖动不该被误报成密码错误。tenantId 权威覆盖是信任边界。两行代码// 用应用所属的权威 tenant_id 覆盖每个事件杜绝客户端伪造跨租户上报。for_,event:rangevalidEvents{event.TenantIdapp.TenantID}客户端上报的tenantId一律作废服务端按appId反查出这个应用属于哪个租户并强制覆盖。这是整个平台防跨租户越权的第一道闸OLAP 层还有第二道见 第 4 篇。响应永远不撒谎。即便 HTTP 200failedCount也可能 0——批量上报里部分事件校验失败时会把失败明细按事件类型分组放进errorsByType返回。SDK 拿到后记 warn而不是误以为全成功。三、Core复杂度的集中地Core 是承载所有重逻辑的服务。它的对外协议是gRPC不直接对前端数据源有两条线业务实体 ──ent ORM──▶ PostgreSQL 应用、用户、角色、权限、事件 Schema…… 分析聚合 ──原生 SQL──▶ OLAP events_fact / sessions_fact / risk_events为什么分析数据要走原生 SQL 而不是 ORM因为漏斗、留存、LTV 这些模型本质是GROUP BY 时间分桶 窗口函数是 OLAP 引擎的主场。用 ent 这类行式 ORM 去拼这些聚合既写不出也跑不动。所以项目做了一个清晰的分层写业务 CRUD→ ent go-crud泛型 Repository自动处理分页 / 过滤 / FieldMask写分析聚合→ 原生 SQLDoris 用db.SelectContextClickHouse 用db.SelectSQL 函数按方言切换。Core 最有意思的部分是双引擎同一份业务模型ClickHouse 和 Doris 各实现一份 repo运行时二选一。这是整个平台最硬核的设计之一我会用整个 第 2 篇 来讲。⚠️一个诚实的现状截至当前版本uba_events_raw/uba_risk_events的Kafka 消费入库逻辑在 Core 内尚未实现。Collector 已经正确 PublishCore 也提供了BehaviorEventService.BatchCreate入库入口但连接两者的 subscriber 缺失。这意味着上报数据目前会停留在 Kafka不会自动落库。生产化前需要补一个消费者在 Core 内订阅 topic 调BatchCreate或引入独立 worker。详见 架构文档 的诚实披露。这种能力具备但管线未接通的状态在真实项目里很常见文档主动写出来比藏着好。四、Admin薄转发 SSEAdmin 是给前端用的 HTTP 网关。它的设计哲学是纯转发不含业务逻辑// admin/service/internal/service/xxx_service.gofunc(s*XxxService)List(ctx context.Context,req*adminV1.ListXxxRequest)(*adminV1.ListXxxResponse,error){returns.client.List(ctx,req)// 直接转发到 Core 的 gRPC client}每个 admin 方法体基本就是return s.client.Method(ctx, req)。业务逻辑全在 Core。这样改前端需求时Admin 层几乎不动权限 / 菜单 / 聚合都收敛到一处。Admin 有两个特色值得展开1. SSE 实时推送站内消息Admin 单独开了一个 SSE 端口5601。配置很简洁server.yamlserver:sse:addr::5601path:/eventsauto_stream:true# 关键按 streamID 自动创建流无需预注册auto_stream: true是点睛之笔——调用方不用预先 createStreamURL 上带?streamid就能自动物化一个流。推送逻辑internal_message_service.go的设计很巧妙// 用收件人的 access token 作为 streamIdrecipientStreamIds,_:s.authenticationServiceClient.GetAccessTokens(ctx,authenticationV1.GetAccessTokensRequest{UserId:recipientUserId,ClientType:authenticationV1.ClientType_admin,})for_,streamId:rangerecipientStreamIds.AccessTokens{s.sseServer.Publish(ctx,sse.StreamID(streamId),sse.Event{Event:[]byte(notification),Data:recipientJson,ID:[]byte(id.NewGUIDv7(false)),// 有序 ID客户端可去重/排序})}Stream ID 用户的 admin access token。一个用户在多个设备登录就有多个 token于是天然实现按设备 fan-out。前端登录后用?streamaccessToken连上后端 Publish 到同一个 streamId闭环就接上了// frontend/.../stores/authentication.state.tsconsttargetSseUrl${import.meta.env.VITE_GLOB_SSE_URL}?stream${accessToken};globalSSEClient.connect(targetSseUrl);// layouts/basic.vueglobalSSEClient.onInternalMessageRecipient(notification,handleSseNotification);事件名notification是前后端硬契约。注意 SSE 这里只用于站内消息通知不是通用事件总线——这是克制的设计。2. 服务发现 动态端口Core 的 gRPC 监听0.0.0.0:0随机端口启动时把我的地址写进 etcdAdmin/Collector 通过 etcd 发现 Core 并建 gRPC 连接。这让 Core 可以多实例部署 滚动更新是微服务的标准操作。五、契约优先一条代码生成管线这套平台最省心的地方是先写.proto/ ent schema再生成多端代码。理解这条管线是二次开发的前提。5.1 Makefile 分层生成命令分两层顶层backend/Makefile负责编排每个服务目录下的 Makefileinclude backend/app.mk提供具体动作。SRCS_MK : $(wildcard app/*/*/Makefile)自动发现所有服务所以make ent/make wire会递归下钻到每个服务。核心命令在backend/下命令产物make apiproto → Gomessages gRPC stub Kratos HTTP validate struct tagmake tsproto → 前端 TS 客户端make openapiproto → Swagger / OpenAPImake entent schema → ORM 实体代码make wire重新生成依赖注入wire_gen.gomake genent wire api openapi不含 ts5.2 buf Managed Mode自动注入 go_packagebuf.gen.yaml开了 managed mode自动给项目 proto 注入go_packagemanaged:enabled:truedisable:# 这些外部模块的 go_package 不许 buf 重写-module:buf.build/googleapis/googleapis-module:buf.build/envoyproxy/protoc-gen-validate-module:buf.build/kratos/apis# ...override:-file_option:go_package_prefixvalue:go-wind-uba/api/gen/go-file_option:go_packagepath:admin/service/v1value:go-wind-uba/api/gen/go/admin/service/v1;adminpb# 显式包名注意disable列表——vendored / registry 模块googleapis、PGV、kratos apis的go_package不能被 buf 覆盖否则会破坏它们的 canonical 路径。5.3 关键细节TS 只对 admin/service/v1 生成这是整个 BFF 模式的核心但容易被忽略。看buf.admin.typescript.gen.yamlinputs:-directory:protospaths:-protos/admin/service/v1# ← 只有这个子树是输入只有admin/service/v1会被生成 TS 客户端。后端内部的 gRPC 服务契约uba/service/v1、internal_message/service/v1、authentication/service/v1等故意被排除。为什么因为前端只跟 Admin BFF 对话而 Admin BFF 已经把这些后端服务的数据重新暴露成它自己的 HTTP 接口。如果把后端内部 proto 也生成 TS会把内部契约泄漏进浏览器 bundle破坏 BFF 边界。这是一个很小但很关键的决策。5.4 ⚠️ ent 生成的坑如果你直接跑ent generate ./schema生成的代码会缺方法编译报错。正确命令在app.mk里ent: ent generate \ --feature privacy \ --feature entql \ --feature sql/modifier \ --feature sql/upsert \ --feature sql/lock \ ./internal/data/ent/schema这五个 feature 是非默认的扩展privacy隐私策略拦截、entql类型化谓词 DSL、sql/modifierModify()原生 SQL、sql/upsertON CONFLICT、sql/lockSELECT FOR UPDATE。裸ent generate生成的 builder 不带这些方法任何调用.Privacy()/.QueryModifier()/.OnConflict()的代码都会编译失败。记住要么make ent要么带全 feature。这是二次开发第一个会踩的坑。 顺带一个发现make ts引用了buf.collector.typescript.gen.yaml但这个文件在backend/api/下不存在——只有buf.collector.openapi.gen.yaml。所以make ts第二个 buf 调用会失败。二次开发时注意补上或去掉这一行。六、前端契约驱动 vue-query ECharts 按需前端Vue 3 Vben Admin的架构也深受契约优先影响。生成的 TS 客户端落在src/api/generated/admin/service/v1/外面再包一层composable。三种 composable 范式api/composables/下 40 个文件每个都导出三种风格的函数以role.ts为例// 1. 响应式读vue-query useQuery—— 在 setup() 里用exportfunctionuseListRoles(query,options?){returnuseQuery({queryKey:[listRoles,query],queryFn:()apiClient.roleService.List(query.toRawParams()),...options,});}// 2. 命令式读queryClient.fetchQuery—— 在路由守卫/store/watch 里用exportasyncfunctionfetchListRoles(params){returnqueryClient.fetchQuery({queryKey:[listRoles,params],queryFn:()apiClient.roleService.List(params.toRawParams()),staleTime:0,retry:0,});}// 3. mutationuseMutation—— 增删改exportfunctionuseUpdateRole(options?){returnuseMutation({mutationFn:({id,values})apiClient.roleService.Update({id,data:{...values}asany,updateMask:makeUpdateMask(Object.keys(values??{})),// FieldMask 部分更新}),...options,});}关键点前两种共享同一个queryKey和同一个queryClient单例所以响应式 hook 和命令式函数读写的是同一个缓存。updateMask用Object.keys(values)生成 FieldMask所以是部分更新而非整对象 PUT——很 proto 风格。分析类 composableanalytics.ts最大用staleTime: 60_0001 分钟缓存而 CRUD 模块用staleTime: 0——因为分析聚合重能接受 1 分钟内的轻微陈旧数据。ECharts 按需注册Vben 默认只注册了BarChart / LineChart / PieChart / RadarChart。项目为 BI 看板额外注册了Funnel、Heatmap、VisualMap、MarkLine、MarkPointecharts.tsecharts.use([TitleComponent,PieChart,RadarChart,TooltipComponent,GridComponent,DatasetComponent,TransformComponent,BarChart,LineChart,FunnelChart,HeatmapChart,ScatterChart,VisualMapComponent,MarkLineComponent,MarkPointComponent,LabelLayout,UniversalTransition,CanvasRenderer,LegendComponent,ToolboxComponent,]);FunnelChart→ 漏斗分析HeatmapChart VisualMapComponent→ 留存矩阵色阶MarkLine / MarkPoint→ 异常检测的事件趋势标注 一个小瑕疵usePathSankey这个 composable 存在但SankeyChart没有在echarts.use里注册。所以路径桑基图要么走了自定义适配要么这是个没接完的点——二次开发时留意。路由自动收录加页面不用手动注册路由。router/routes/index.tsconstdynamicRouteFilesimport.meta.glob(./modules/**/*.ts,{eager:true});constdynamicRoutes:RouteRecordRaw[]mergeRouteModules(dynamicRouteFiles);modules/下任何新.ts文件都会被import.meta.glob自动收录——加一个modules/app/foo.ts导出路由数组菜单里就有了。七、小结这套架构的可借鉴之处回到最初的问题这套架构值不值得抄我觉得有几条是超越了UBA 平台本身、可以迁移到任何数据密集型后台的设计原则职责切片按状态和复杂度分而不是按业务领域分。Collector 无状态纯 IOCore 承载所有状态和复杂度Admin 薄转发。这样扩展性和可维护性都好。契约优先 代码生成是前后端协同的杠杆。proto 改一处Go / TS / OpenAPI / struct tag 全部跟着变。代价是管线本身有学习曲线ent feature、buf managed mode。BFF 模式要落实到生成边界。只对admin/service/v1生成 TS是用工具链强制守住内部契约不外泄——这比口头约定可靠得多。SSE 用 access token 当 streamId实现按设备 fan-out。一个巧思省了一个独立的消息分发服务。诚实的文档。Kafka 消费未实现、双引擎是编译期常量——这些写进 README 的取舍让项目可信。本文代码均出自 go-wind-uba 仓库。如有疑问欢迎到仓库 issue 讨论。