11 · 代码地图与事实索引(Vistack)
定位:被问到「这段在哪、怎么实现的」时能 10 秒定位;也是自己复习的目录。 用法:
Ctrl+F搜模块名或文件名。所有路径相对仓库根/Users/binhy/Binhy-Projects/Vistack;行号是写作时的快照,重构后可能偏移,请以符号名为准。 纪律:本文件只写代码里能验证的事实。需要确认的地方标「需确认」。
目录树速览
Vistack
├── cmd/vistack/main.go # 唯一入口:解析角色 → 初始化日志/配置 → 分发到 internal/role
├── conf/ # app.toml(最全)/ app.local.toml(本地覆盖)/ app.docker.toml(容器)
├── internal/
│ ├── role/ # 5 个角色的启动引导:api.go / worker.go / transcoder.go / auth.go / migrate.go
│ ├── api/v1/ # HTTP 处理器(Gin handler):Video/File/social/danmaku/comment/sensitive_word/health/web/authclient
│ ├── routers/ # 路由注册
│ │ ├── router.go # RegisterRoutes:公开组 / 鉴权+限流组
│ │ ├── health.go # /ping /health
│ │ └── api/v1/ # 每个领域的路由文件:video.go/file.go/social.go/danmaku.go/comment.go/enter.go
│ ├── core/ # 基础设施封装
│ │ ├── db.go / redis.go / minio.go / kafka.go / snowflake.go / cache.go
│ │ ├── logger.go / server.go / vipper.go / validate.go
│ │ ├── cache/ # 通用 Cache-Aside 组件 + Redis 布隆过滤器
│ │ ├── leader/ # etcd 领导选举(单例任务)
│ │ └── message_queue/ # Kafka 消费者:transcode/(worker+retry+watchdog)、video/(删除)、danmaku/、comment/
│ ├── middlewares/ # auth.go / cors.go / requestid.go / ratelimit.go
│ │ └── ratelimit/ # limiter.go(接口)、token_bucket.go(单机)、sliding_window.go(Redis Lua)
│ ├── interaction/ # 点赞/收藏/播放计数 + 榜单 + 异步落库(interaction.go / flusher.go / leaderboard.go / keys.go)
│ ├── danmaku/ # 弹幕(danmaku.go / sensitive.go AC 自动机 / local_cache.go LRU / keys.go)
│ ├── comment/ # 评论(comment.go / counter.go / moderation.go / attachment.go / keys.go)
│ ├── auth/ # auth 服务:handler.go(HTTP)+ service.go(gRPC)+ pb/auth/v1
│ ├── authclient/ # api 侧调用 auth 的用户查询客户端
│ ├── transcoder/ # 转码服务:service.go(流程)/ ffmpeg.go(命令与档位)/ server.go / client.go / registry/ / pb/
│ ├── discovery/etcd.go # 自研 gRPC resolver(scheme=etcd)
│ ├── config/ # AppConfig 结构 + 默认路径常量
│ ├── consts/Kafka.go # 4 个 topic 名
│ ├── global/ # 全局 AppConfig 实例
│ └── model/entity/ # GORM 实体:video/ file/ user/ social/ danmaku/ tag/ audit/
├── pkg/ # 可复用库:auth/(JWT+JWKS)hashutil/ snowflake/ storage/ timeutil/ response/(未使用)
├── proto/ # buf 管理的契约:auth/v1/auth.proto、transcoder/v1/transcoder.proto
├── migrations/migrate.go # AutoMigrate + 索引 + 默认角色
├── db/init.sql # 历史 SQL 脚本(未挂载、与实体已漂移,见「已知缺口」)
├── deploy/
│ ├── k8s/ # api.yaml / worker.yaml / transcoder.yaml / etcd.yaml / configmap.yaml
│ ├── traefik/ # traefik.yml(静态)/ dynamic.yml(路由分流)
│ └── cdn-publish.sh # 前端发 CDN(pages / s3 / dry)
├── docs/ # specs/(10 个子系统 spec + distributed-architecture.md)、review/、img/
├── web/ # pnpm monorepo
│ ├── ui/src/api/axios.ts # 共享 axios 客户端(baseURL=VITE_API_BASE,超时 15s)
│ ├── web-client/src/ # 用户端(Vue3):views/Creator(上传)、views/VideoPlayer(播放)、components/player-dash
│ └── web-admin/src/ # 管理端(仅登录 + 违禁词管理)
├── compose.yml / compose-build.yml / compose.server.yml
├── Dockerfile # 三阶段,两个运行 target:vistack / vistack-transcoder
└── build/ # 前端构建产物输出目录(vite outDir:build/web-client、build/web-admin、build/cdn)
web/ui/dist、node_modules、.cache(gomod 缓存)不是源码目录,复习时忽略。
模块 → 文件 → 关键函数
1. 入口与角色引导
| 模块 | 文件 | 关键符号 | 一句话职责 | 面试常问点 |
|---|---|---|---|---|
| 入口 | cmd/vistack/main.go | main、resolveRole | 角色解析(VISTACK_ROLE → 首个位置参数 → api),依次初始化 Viper、zap、配置校验,再 switch 分发 5 个角色 | 为什么单二进制?角色怎么选?compose/k8s 实际用的是位置参数 |
| 角色 | internal/role/api.go | RunAPI、defaultJWKSURL | DB/MinIO/Redis/Cache/Snowflake/JWKS 验签器/auth user client/Kafka producer → 装配三个异步服务 → 注册路由与静态托管 → 30s 优雅停机 | 为什么 api 不消费 Kafka?JWKS 为什么要自动刷新? |
| 角色 | internal/role/worker.go | RunWorker、runSingletonJobs、instanceID | 4 个消费者 + etcd 领导选举下发单例任务;停机时 WaitKafkaConsumers(30s) | dispatcher/watchdog 为什么必须单例?etcd 不可用怎么降级? |
| 角色 | internal/role/transcoder.go | RunTranscoder | 只 InitMinioClient,启动 gRPC 服务;不连 DB/Redis/Kafka | 无状态的好处?参数从哪来? |
| 角色 | internal/role/auth.go | RunAuth、loadOrGeneratePrivateKey、advertiseAuthAddr、localAuthIP | gRPC(:50052) + etcd 注册 + HTTP(:8081) + JWKS;私钥来自环境变量/文件,缺失则生成临时 2048 位密钥并告警 | 私钥怎么治理?开发环境临时密钥的风险? |
| 角色 | internal/role/migrate.go | RunMigrate | 跑一次 migrations.AutoMigrate 后退出,避免多副本 api 竞争迁移 | 为什么迁移要独立角色?幂等靠什么? |
2. 路由与中间件
| 模块 | 文件 | 关键符号 | 一句话职责 | 面试常问点 |
|---|---|---|---|---|
| 路由 | internal/routers/router.go | RegisterRoutes | /(health)、/api/v1 公开组(video 播放、social、danmaku 读、comment 读)、/api/v1 鉴权组(挂 AuthMiddleware + RateLimit,含 file/video 写/social 写/danmaku 写/comment 写/敏感词) | 哪些接口需要登录?限流挂在哪一层? |
| 路由 | internal/routers/api/v1/{video,file,social,danmaku,comment}.go | InitXxxPublicRouter / InitXxxPrivatesRouter | 按领域把 handler 注册到公开组/鉴权组 | 能背出 /videos/upload/* 与播放相关的路径 |
| 路由 | internal/routers/health.go | HealthRouter.InitHealthRouter | /ping、/health | 与 k8s 探针的关系(没接) |
| 中间件 | internal/middlewares/auth.go | AuthMiddleware | 取 Authorization → 兼容 Bearer → TokenValidator.ValidateToken → claims 写入 gin 上下文 | 为什么注入接口而不是直接调 verifier? |
| 中间件 | internal/middlewares/ratelimit.go | BuildLimiter、RateLimit、SetLogger | 按配置构造限流器(token_bucket / sliding_window),按 userID 限流,Redis 故障 fail-open,写标准限流头 | 为什么按用户 ID 而不是 IP?fail-open 的取舍 |
| 中间件 | internal/middlewares/ratelimit/{limiter,token_bucket,sliding_window}.go | Limiter/Result、NewTokenBucket/Allow、NewSlidingWindow/Allow | 两种算法的实现;滑动窗口是一段 Lua 脚本 | Lua 里为什么用 uuid 当 member? |
| 中间件 | internal/middlewares/{cors,requestid}.go | CORS、RequestID | 白名单 CORS;每请求注入 X-Request-ID | AllowCredentials 与 * 的处理 |
3. HTTP 处理器(internal/api/v1)
| 模块 | 文件 | 关键符号 | 一句话职责 | 面试常问点 |
|---|---|---|---|---|
| 上传 | Video.go | InitVideoUpload | 秒传查重 → 续传会话 → NewMultipartUpload,返回 uploadId/objectKey | 秒传事务里建了几张表?ref_count 怎么加 |
| 上传 | Video.go | GetUploadPartURL、ListUploadedParts | 用公网 Core client 签 PUT(1h);列已传分片(maxParts 10000) | 为什么用 Presign 而不是 PresignedPutObject |
| 上传 | Video.go | CompleteVideoUpload | 合并分片 + 事务建 4 张表 + 投 Kafka(失败则标 failed 并入重试队列) | 一致性边界在哪?失败如何兜底 |
| 视频 | Video.go | DeleteVideo | 软删 + 失效缓存 + 投 delete_file(不动 ref_count,交给 worker) | 为什么 API 不扣引用计数 |
| 视频 | Video.go | GetVideoInfo、GetSelfVideoPage、PutVideoInfo、GetVideoRecommend | 详情(带布隆的读缓存)、创作中心分页、改元信息(删缓存)、推荐(固定 TTL) | 缓存 key 是什么?写路径怎么失效 |
| 播放 | Video.go | GetVideoMdp、GetVideoSegmentsSignature | MPD 代理下发(max-age=3600);STS 临时凭证 + 受限 policy + Redis 缓存 25min | STS 为什么用 root 凭证(已知问题);policy 怎么限资源 |
| 文件 | File.go | AvatarUpload、CoverUpload、CommentImageUpload | 5MB 限制 + 前缀分类 + 建 files 记录 | 只校验大小的风险 |
| 互动 | social.go | LikeVideo/FavoriteVideo/PlayVideo/GetVideoStats/GetVideoInteraction/GetHotVideos/enrichCounts | toggle 点赞收藏、播放上报、三计数读取(Redis→DB 回退)、ZSet 榜单 | 计数以谁为准?榜单怎么过滤已删视频 |
| 弹幕 | danmaku.go、sensitive_word.go | SendDanmaku/GetDanmaku、List/Add/DeleteSensitiveWord | 按时间区间拉取、发送(鉴权+限流)、敏感词 CRUD | Cache-Control 为什么是 5s;cache_control_max_age 配置其实没被用 |
| 评论 | comment.go | ListComments/ListReplies/CreateComment/ToggleLike/DeleteComment/CommentCount/buildItems/toItem | 游标分页 + 首屏内嵌 2 条回复 + 批量补作者与附件 URL | 游标分页为什么不用 offset;N+1 在哪 |
| 缓存 | video_cache.go | getOrLoad/deleteCache/addVideoBloom/BuildVideoBloom/videoInfoCacheKey/recommendCacheTTL | 封装 core.Cache,core.Cache == nil 时直接回源兜底 | 关掉缓存时行为是否一致 |
| 静态 | web.go | RegisterWebStatic、spaFileSystem.Open | / 与 /admin/ 托管构建产物,SPA 回退,/api/ 保持 404 | SPA 回退为什么排除 /api/ |
| 健康 | health.go | Ping、HealthCheck | 探测 DB(1s)/MinIO(2s)/Redis(1s),恒返回 200 | 为什么不能当 readiness 探针 |
| 作者 | authclient.go | SetUserClient、resolveAuthor、resolveAuthors、toAuthor | 经 gRPC 批量查作者信息(去重后一次 RPC) | 为什么不让 api 直接 join 用户表 |
4. 基础设施(internal/core)
| 模块 | 文件 | 关键符号 | 一句话职责 | 面试常问点 |
|---|---|---|---|---|
| 配置 | vipper.go | Viper、GetConfigPath | 加载 toml,VISTACK_ 前缀自动映射,WatchConfig 只打日志 | -c / VISTACK_CONFIG_PATH 优先级 |
| 配置校验 | validate.go | ValidateConfig | 空实现(_ = cfg) | 诚实承认:配置校验如今是空壳 |
| 日志 | logger.go | InitLogger、SyncLogger | zap Production + ISO8601 时间 + level 可配 | 结构化日志字段有哪些 |
| 服务器 | server.go | NewServer | gin.New + ginzap 访问日志 + RecoveryWithZap + RequestID + CORS | 中间件顺序 |
| DB | db.go | InitDB | postgres DSN 或字段拼装;连接池与 Ping | 连接池三参数 |
| Redis | redis.go | InitRedis | host 为空则跳过;池大小与超时 | 未配 Redis 时的降级行为 |
| MinIO | minio.go | InitMinioClient、GetPublicBaseURL、GetInternalBaseURL、GetMinioObjectPublicURL | 三个客户端(内网/Core/公网 Core),建桶、写 policy、配生命周期 | 为什么要区分内网与公网 endpoint |
| Kafka | kafka.go | InitKafka、SendKafkaMessage、EnsureTopic、StartKafkaConsumer、runConsumer、WaitKafkaConsumers | 同步 Producer;建 topic(1 分区 1 副本);同组多 reader;手动提交 | 并发上限怎么算;commit 时机 |
| 主键 | snowflake.go | InitSnowflake、deriveNodeID | node_id 显式优先,否则 FNV32a%1024 派生 | 碰撞风险(已知) |
| 缓存装配 | cache.go | InitCache | 默认值兜底 + 构造 cache.Cache 与全局布隆 | 关掉缓存会发生什么 |
| 缓存组件 | cache/cache.go | New、GetOrLoad、Delete、loadAndCache、acquireLock、releaseLock、randomTTL、waitAndRead | 空值缓存 + singleflight + Redis 互斥锁 + 随机 TTL | 锁的释放为什么用 Lua;等锁失败为什么直接回源 |
| 布隆 | cache/bloom.go | NewBloom、positions、Build、Add、Exists | Redis bitmap + FNV-1a 双哈希;:ready 未就绪时降级为「可能存在」 | 假阳性/删除的影响 |
| 选举 | leader/leader.go | New、Run、waitRetry、DefaultLeaderKey | etcd 会话 + Election;失去领导权时 cancel leadCtx 并重选 | 无主窗口多大;为什么优于 Redis 锁 |
| 转码消费 | message_queue/transcode/worker.go | StartTranscodeWorker、handleTranscodeMessage、markFailed、persistTranscodeResult | 幂等 → lease → gRPC(25min) → 事务落库 | 三层时间常数为什么逐级放大 |
| 重试 | message_queue/transcode/retry.go | scheduleDelay、AddTranscodeRetry、StartTranscodeRetryDispatcher | 指数退避写 ZSet;每 5s 取到期最多 100 条回投 | 为什么用 ZSet 而不是延迟 topic |
| 看门狗 | message_queue/transcode/watchdog.go | StartTranscodeWatchdog | 每分钟扫 processing>15min(且无 lease)与 pending>10min | pending 分支为什么要先碰 updated_at |
| 删除 | message_queue/video/delete_video_worker.go | StartVideoDeleteWorker、handleVideoDeleteMessage、processFile | TX1 引用计数 −1 并标 deleting → TX2 二次校验真实引用数并删行 → 提交后删 MinIO 对象与 DASH 前缀 | 两段事务为什么分开;重投幂等性(缺陷) |
| 弹幕落库 | message_queue/danmaku/worker.go | StartDanmakuWorker | OnConflict DoNothing 按主键幂等落库 | 幂等键是什么 |
| 审核 | message_queue/comment/worker.go | SetModerator、StartCommentModerationWorker、handleModerationMessage | 消费 comment_moderation 并调审核器 | 审核器目前是桩实现 |
5. 领域服务
| 模块 | 文件 | 关键符号 | 一句话职责 | 面试常问点 |
|---|---|---|---|---|
| 互动 | interaction/interaction.go | NewService、ToggleLike、ToggleFavorite、RecordPlay、Counts、IsLiked、IsFavorited | 三段 Lua 脚本把「状态+计数+榜单+事件」做成原子操作 | 为什么用 Set 而不是计数器去重 |
| 互动 | interaction/flusher.go | popEvents、applyEvents、syncCounts、syncOneCount、FlushPending、StartFlusher | 批量弹出事件 → 净效果落库 → Redis 计数回写 videos | 净效果去重解决了什么;丢批风险 |
| 互动 | interaction/leaderboard.go | Hot | ZRevRange 取 top N | 榜单无时间窗(已知) |
| 弹幕 | danmaku/danmaku.go | NewService、Send、Fetch、LoadSensitiveWords、AddSensitiveWord、DeleteSensitiveWord | 敏感词 → ZSet 实时 → Kafka 异步;读取三级缓存 | 为什么发完立刻可见(先写 Redis) |
| 弹幕 | danmaku/sensitive.go | SensitiveFilter、Reload、buildFail、Contains | AC 自动机,atomic.Pointer 原子替换根节点支持热更新 | 为什么能无锁并发读 |
| 弹幕 | danmaku/local_cache.go | NewLocalCache、Get、Set | 进程内 LRU + TTL(懒过期) | 为什么 TTL 只有 2 秒 |
| 评论 | comment/comment.go | NewService、Create、List、queryRoots、ListReplies、CommentCount、Delete、cacheList | 楼中楼推导(root/parent/reply_to)+ 事务插入 + 计数 + 审核投递;首屏列表缓存 60s | 三级指针分别是什么;游标分页实现 |
| 评论 | comment/counter.go | ToggleLike、IsLiked、LikeCount、StartFlusher、flushLikes | 评论点赞 Lua toggle + 异步落库 | 与视频点赞的差异 |
| 评论 | comment/moderation.go | Moderator、PassthroughModerator、EnqueueModeration、ProcessModeration、ApplyModerationResult | 可插拔审核;状态机 pending→visible/hidden,拒绝时回滚计数 | 为什么是幂等的(状态守卫) |
| 评论 | comment/attachment.go | ParseAttachments、marshalAttachments、validateAttachments | 附件 JSON 编解码 + 上限 9 + ref_type 校验 | 为什么要校验 ref_type |
| 认证 | pkg/auth/token_manager.go | NewTokenManager、GenerateToken、ValidateToken、PublicJWKS、parseRSAPrivateKey | RS256 签发 + 私钥验签 + 输出 JWKS(支持 PKCS1/8) | kid/issuer 从哪里来 |
| 认证 | pkg/auth/verifier.go | NewTokenVerifier、StartAutoRefresh、ValidateToken、refreshKeys、getKey | JWKS 公钥缓存 + kid 未命中强制刷新 + 每小时定时刷新 | 为什么消费方不持私钥 |
| 认证 | pkg/auth/claim.go | Claims、NewClaims、GetClaim、GetUserID | claims 结构(只有 user_id)与上下文读取 | 没有角色位导致无法做授权 |
| 认证 | internal/auth/handler.go | NewHandler、RegisterRoutes、Register、Login、GetUserInfo、UpdateProfileDirect、UpdateUserPassword、JWKS | 注册/登录/资料/改密/JWKS 端点 | 昵称唯一性靠 Count(竞态);登录不校验 banned |
| 认证 | internal/auth/service.go | NewService、GetUserInfos、toProtoUserInfo | gRPC 批量返回用户公开信息(昵称/头像/角色) | 头像 URL 怎么拼 |
| 认证 | internal/authclient/client.go | NewUserClient、GetUserInfos、Close | api 侧客户端(etcd 发现或静态地址) | 与 transcoder client 的对称性 |
| 杂项 | pkg/hashutil/bcrypt.go | HashPassword、CheckPasswordHash | bcrypt 封装 | 默认 cost |
| 杂项 | pkg/timeutil/rand.go | RandomRangeExpire | [min,max) 随机时长,用于防雪崩 | 为什么随机 TTL 有效 |
6. 转码服务与发现(internal/transcoder、internal/discovery)
| 模块 | 文件 | 关键符号 | 一句话职责 | 面试常问点 |
|---|---|---|---|---|
| 服务 | transcoder/service.go | Service、ProcessVideo | 下载 → 探时长 → 抽帧 → DASH 转码 → 全量上传 → 返回元数据 | 临时目录怎么清理;封面失败是否致命 |
| ffmpeg | transcoder/ffmpeg.go | GetVideoResolution、GetVideoDuration、ExtractVideoFrame、SelectAdaptiveQualities、filterQualities、ResolveQualities、TranscodeToDASH | 档位表与选择策略、单条 ffmpeg 命令生成 | 为什么单条命令多档;GOP 与分片对齐 |
| 服务端 | transcoder/server.go | RunServer、advertiseAddr、localIP | gRPC 监听 + etcd 注册 + GracefulStop | POD_IP 为什么必须注入 |
| 客户端 | transcoder/client.go | NewClient、ProcessVideo、Close | etcd 发现(etcd:///prefix + round_robin)或静态地址 | 两种模式的切换条件 |
| 注册 | transcoder/registry/etcd.go | Register、keepAlive、Close | 租约 TTL 10s + 3s KeepAliveOnce,失败重 Grant+Put | 为什么建议换 KeepAlive 通道 |
| 发现 | discovery/etcd.go | NewEtcdBuilder、Scheme、Build、watch、update、ResolveNow、Close | 实现 resolver.Builder,Watch 前缀变化刷新地址列表 | 为什么不直接用 k8s DNS |
7. 数据模型与迁移
| 模块 | 文件 | 关键符号 | 一句话职责 | 面试常问点 |
|---|---|---|---|---|
| 迁移 | migrations/migrate.go | AutoMigrate、initDefaultRoles | 20 个模型按依赖顺序迁移;补列长度、两个索引、attachments 回填;种 4 个默认角色 | 为什么 resolution 要手工 ALTER |
| 实体 | model/entity/video/{video,transcode,manifest}.go | Video/VideoSource/VideoTranscode/VideoManifest + 状态枚举 | 视频主表/源/转码任务/播放清单 | 状态机怎么流转 |
| 实体 | model/entity/file/file.go | File、PublicURL、FileStatus、FileRefType | 物理文件的统一登记表 + 引用计数 | ref_count 语义(易错) |
| 实体 | model/entity/user/*.go | User/UserProfile/Role/Authority/RoleAuthority/UserAuthority | RBAC 数据模型(有表无执行点) | 诚实承认未做授权判定 |
| 实体 | model/entity/social/*.go | VideoComment、CommentLike、VideoLike、VideoFavorite、VideoPlayLog | 评论与互动明细 | 复合主键的作用 |
| 实体 | model/entity/danmaku/*.go | Danmaku、SensitiveWord | 弹幕与敏感词 | time_offset 无索引(已知) |
| 实体 | model/entity/tag/tag.go、audit/audit.go | Tag、VideoTag、AuditLog | 标签(未接 API)、审计日志(迁移被注释,未建表) | audit 是死代码 |
8. 前端(web/)
| 模块 | 文件 | 关键符号 | 一句话职责 | 面试常问点 |
|---|---|---|---|---|
| 上传 | web-client/src/views/Creator/index.vue | calculateHash、startUpload、chunkSize、concurrency | 2MB 分块算 MD5 → init → 列分片 → 6 并发签名直传 → complete | 8MB/6 并发/2MB 三个数各自的理由 |
| 上传 API | web-client/src/views/Creator/api/api.ts | initVideoUpload、getUploadPartUrl、listUploadedParts、completeVideoUpload、getMyVideos、deleteVideo | 4 个上传接口 + 列表/删除 | 接口路径与后端绑定字段要对得上 |
| 播放 | web-client/src/views/VideoPlayer/index.vue | loadSignature、scheduleRefresh、loadDanmaku、reportPlay | 拉 STS 与 MPD、提前 2 分钟刷新、拉弹幕、上报播放 | 为什么播放上报是公开接口 |
| 播放器 | web-client/src/components/player-dash/useDashPlayer.ts | setupPlayer、updateStsConfig、buildSegmentUrl、setQuality、addRequestInterceptor | dash.js 初始化 + 请求拦截签名 + 清晰度控制 | 拦截器只改 .m4s;无错误处理 |
| 签名 | web-client/src/lib/s3-signer.ts | signS3Request、getSignatureKey、buildCanonicalQuery | 手写 AWS SigV4(js-sha256) | UNSIGNED-PAYLOAD 的含义 |
| 弹幕渲染 | web-client/src/components/player-dash/danmaku/useDanmaku.ts、DanmakuBar.vue | useDanmaku、canvas 渲染参数 | 弹幕引擎(滚动/固定/队列上限 400) | 为什么用 canvas 不用 DOM |
| HTTP | web/ui/src/api/axios.ts | baseURL、get/post/put/del、token 拦截器 | 统一客户端(15s 超时、Bearer、401 清 token) | VITE_API_BASE 缺省 /api |
三条核心链路逐跳追踪
链路 ① 上传(浏览器 → api → MinIO → Kafka)
- 用户选文件 →
onFileChange(Creator/index.vue:52)重置fileHash。 calculateHash(index.vue:83-119):FileReader.readAsArrayBuffer按 2MB 逐块喂SparkMD5.ArrayBuffer,spark.end()得整文件 MD5;进度映射到 0–10%。initVideoUpload→POST /api/v1/videos/upload/init→VideoApi.InitVideoUpload(Video.go:81):- 3a. 秒传:
files WHERE hash=? AND status='active'(Video.go:100)命中 → 事务内ref_count+1、建Video(processing)、建VideoSource(复用同一 file_id)、建VideoTranscode(pending)(Video.go:102-149)→addVideoBloom(:152)→SendKafkaMessage(transcode)(:161)→ 返回{uploaded:true, video_id},前端直接结束(index.vue:152-159)。 - 3b. 续传:Redis
upload_session:<userID>:<fileHash>命中则原样回放(Video.go:184-193)。 - 3c. 新建:
objectKey = raw/<uuid><ext>(:196-198)→core.MinioCore.NewMultipartUpload(:202)→ 会话写 Redis 24h(:220)→ 返回{upload_id, object_key, bucket}。
- 3a. 秒传:
listUploadedParts→GET /videos/upload/parts→ListObjectParts(maxParts=10000)(Video.go:298)→ 前端构造uploadedMap,算出缺失的partsToUpload(index.vue:161-183)。- 每个分片(最多 6 个 worker 并行,
index.vue:192-250):getUploadPartUrl→GET /videos/upload/sign→MinioCorePublic.Presign("PUT", bucket, objectKey, 1h, {uploadId, partNumber})(Video.go:264);fetch(url, {method:'PUT', body: chunk})直传 MinIO(index.vue:215-218),ETag 取自响应头(:225);- 失败 → 把 partNumber 塞回队列、
setTimeout 2000ms重试(无上限,:237-244)。
completeVideoUpload→POST /videos/upload/complete→CompleteVideoUpload(Video.go:329):CompleteMultipartUpload合并对象(:343);- 开事务:
Video(processing)→File(video_source, ref_count=1, hash, mime=video/mp4)→VideoSource→VideoTranscode(pending)(:351-408)→ Commit; addVideoBloom(:411);SendKafkaMessage("transcode", key=videoID)(:423)——失败则标failed+AddTranscodeRetry(attempt=1)+ 返回 500(:426-434);- 删除 Redis 上传会话(
:440)→ 返回video_id。
- 前端展示
已提交转码,视频 ID: xxx(index.vue:699);没有转码状态轮询,只有重新拉列表才更新状态。
涉及文件:函数:views/Creator/index.vue:calculateHash/startUpload、views/Creator/api/api.ts:*、api/v1/Video.go:InitVideoUpload/GetUploadPartURL/ListUploadedParts/CompleteVideoUpload、core/minio.go:InitMinioClient、core/kafka.go:SendKafkaMessage、pkg/storage/minio.go:UploadFile(仅小文件)。
链路 ② 转码(Kafka → worker → transcoder → 事务落库)
- worker 启动
StartTranscodeWorker→core.StartKafkaConsumer("transcode", handleTranscodeMessage)(mq/transcode/worker.go:37-39),实例内起 4 个同组 reader(受 1 分区限制实际并发 1)。 handleTranscodeMessage(:41)解 JSON 得{video_id, transcode_id, object_key, attempt}。- 幂等 ①:
First(&VideoTranscode, transcodeID),status == completed直接 return(:49-55)。 - 幂等 ②:
Redis.SetNX("lease:transcode:<id>", "1", 30min),抢不到就跳过;defer Del(:57-62)。 - 置
processing(:64)。 - 组装
ProcessVideoRequest:bucket、object_key、output_prefix=dash/<videoID>、cover_object_key=covers/<videoID>.jpg、cover_time_seconds=0(:72-78)。 context.WithTimeout(25min)(:80)→transcoderClient.ProcessVideo(:83);gRPC 经自研 resolver +round_robin打到某个 transcoder 实例。- transcoder
Service.ProcessVideo(transcoder/service.go:25):os.MkdirTemp建临时目录(:30,defer 清理);Minio.FGetObject下载原片为input.mp4(:37);GetVideoDuration(:42,ffprobeformat=duration);- 抽帧时间:请求值优先,否则 >10s→5s,>2s→duration/2,其余 1.0s(
:51-59); ExtractVideoFrame+FPutObject上传封面(:65-78,失败只告警);ResolveQualities(0,0,req.QualityHeights)→TranscodeToDASH(:87-88,单条 ffmpeg 命令多档输出到本地output/);filepath.Walk遍历产物,按扩展名设 Content-Type(.mpd→application/dash+xml、.m4s→video/iso.segment)逐个上传(:98-136);- 返回
duration/manifest(key,size)/cover(key,size)/profiles(:146-153)。
- 失败路径:
markFailed(:92)→ 标failed+INCR attempts:transcode:<id>(TTL 24h)+cnt<=7就AddTranscodeRetry(:94-102),handler 返回nil(不重投 Kafka)。 - 成功路径:
persistTranscodeResult(:106)单事务:- 建 manifest
File(video_manifest, application/dash+xml)(:110-121); - 更新
VideoTranscode:completed、manifest_file_id、resolution(逗号拼接)、codec="h264,aac"(:130-140); - 建
VideoManifest(protocol=dash, profiles=JSONB, status=ready)(:143-153); - 有封面则建 cover
File(:155-170); - 更新
Video:status=published、duration、cover_file_id(:172-184);Commit(:186)。
- 建 manifest
Redis.Del("attempts:transcode:<id>")(:190)。- 兜底链:
StartTranscodeRetryDispatcher每 5s 从transcode:retry:zset取到期 ≤100 条回投 Kafka,成功才 ZRem(retry.go:46-81);StartTranscodeWatchdog每 1 分钟扫processing超 15min(且 lease 已不存在)与pending超 10min 的任务重新入队,attempts>7 则放弃(watchdog.go:14-72)。两者都由 etcd 领导选举保证单例(role/worker.go:79-122)。
涉及文件:函数:core/kafka.go:StartKafkaConsumer/runConsumer、mq/transcode/{worker,retry,watchdog}.go、core/leader/leader.go:Run、transcoder/{client,server,service,ffmpeg}.go、transcoder/registry/etcd.go:Register、discovery/etcd.go:Build。
链路 ③ 播放(MPD 代理 → STS → dash.js 拦截签名 → MinIO 直连)
- 进入播放页
onMounted并发发起 6 个请求(VideoPlayer/index.vue:223-232):loadSignature、loadVideoInfo、loadRelated、loadStats、loadInteraction、reportPlay。 loadSignature→GET /api/v1/videos/:id/segments/signature→GetVideoSegmentsSignature(Video.go:772):- 查视频存在(
:780-784); - Redis
video:sts:<id>命中则直接用(:786-793); - 未命中:构 policy 只允许
s3:GetObject于arn:aws:s3:::<bucket>/dash/<id>/*(:798-807)→credentials.NewSTSAssumeRole(内网 endpoint, {root AK/SK, policy})(:811-828)→expiration = now+30min(:837)→ 写 Redis 25min(:846-853); - 返回
{base_url: "<publicBase>/<bucket>/dash/<id>/", credentials:{accessKey,secretKey,sessionToken,expiration}}(:865-870)。
- 查视频存在(
<DashPlayer>只在manifestUrl && segmentsBaseUrl && segmentsCredentials三者都有时渲染(index.vue:264),manifest 地址是 axios baseURL 拼接的绝对 URL(:37-40),并由 dash.js 直接 fetch(不走 axios 拦截器)。GET /api/v1/videos/:id/manifest.mpd→GetVideoMdp(Video.go:721):查VideoManifest(video_id, protocol=dash, status=ready, Preload File)(:737)→Minio.GetObject(:748)→ 设Content-Type: application/dash+xml+Cache-Control: public, max-age=3600(:757-759)→DataFromReader流式返回(:768)。- dash.js 初始化:
MediaPlayer().create()→updateSettings(abr.autoSwitchBitrate.video=true)→addRequestInterceptor(...)(useDashPlayer.ts:67-81)→initialize(video, url, autoplay)(:97)。 - 每个分片请求进入拦截器(
useDashPlayer.ts:69-81):buildSegmentUrl:只对 以.m4s结尾 的请求改写成base_url + filename(:48-61);signS3Request(finalUrl,'GET','us-east-1',creds,new Date())生成Authorization/x-amz-date/x-amz-content-sha256(+x-amz-security-token)(s3-signer.ts:68-111);- 写回
req.url与req.headers,浏览器直连 MinIO(生产经 Traefik/vistack→minio:9000)。
streamInitialized事件:从getRepresentationsByType('video')生成清晰度列表(height/bandwidth),QualitySelector切档走setQuality(关掉 autoSwitchBitrate +setRepresentationForTypeByIndex)(useDashPlayer.ts:82-96,103-113)。- 凭证续期:
scheduleRefresh(expiration, id)在过期前 2 分钟重新调 signature 接口(时间无效则 10s 后重试)(index.vue:206-221);segmentsCredentials变化触发updateStsConfig,拦截器用最新凭证(useDashPlayer.ts:175-187)。 - 旁路请求:弹幕
GET /videos/:id/danmaku?start=0&end=<max(60,duration)>(index.vue:109-117)、评论与点赞各自独立接口,与播放链路解耦。
涉及文件:函数:VideoPlayer/index.vue:loadSignature/scheduleRefresh/loadVideoInfo、VideoPlayer/index.ts:getVideoSegmentsSignature、player-dash/useDashPlayer.ts:setupPlayer/updateStsConfig/buildSegmentUrl/setQuality、lib/s3-signer.ts:signS3Request、api/v1/Video.go:GetVideoSegmentsSignature/GetVideoMdp、core/minio.go:GetPublicBaseURL/GetInternalBaseURL、deploy/traefik/dynamic.yml(media-router)。
数据库表结构
真实 schema 由
migrations/migrate.go:AutoMigrate生成(20 个模型);db/init.sql是历史脚本、已与实体漂移(见「已知缺口」)。audit_logs实体存在但迁移被注释掉,表不会创建。
| 表 | 实体 | 关键字段 | 用途 / 注意点 |
|---|---|---|---|
roles | user.Role | id PK、name unique、description | 默认种 4 个:superadmin/admin/user/vipuser(initDefaultRoles) |
users | user.User | id PK、username unique、email、password_hash、role_id、status(active/banned)、created_at | password_hash 带 json:"-";登录不校验 banned(已知) |
user_profiles | user.UserProfile | id PK、user_id、nickname(unique)、avatar_file_id | 昵称唯一靠 unique 约束;注册时另用 Count 预检(有竞态) |
authorities | user.Authority | id PK、resource_method、resource_uri | RBAC 权限定义(无执行点) |
role_authority | user.RoleAuthority | id PK、role_id、authority_id | 角色-权限关联 |
user_authority | user.UserAuthority | id PK、user_id、authority_id、grand_status、remark | 列名拼写是 grand_status(历史遗留) |
files | file.File | id PK、bucket、object_key、status(active/deleting/deleted)、ref_type、mime_type、hash(index)、size、ref_count、created_at/updated_at | 物理文件登记 + 引用计数;hash 是秒传依据;ref_type 六种(avatar/video_source/transcode_artifact/video_manifest/video_cover/comment_image) |
videos | video.Video | id PK、user_id、title、description、cover_file_id、duration、status(uploaded/processing/published/failed/deleted)、visibility(public/private/unlisted)、like_count/favorite_count/play_count 冗余列、created_at/updated_at | 状态机主表;三计数由 Redis 权威异步回写 |
video_sources | video.VideoSource | id PK、video_id、file_id、uploaded_at | 原始视频与 files 的关联(一个视频可多源) |
video_transcodes | video.VideoTranscode | id PK、video_id、status(pending/processing/completed/failed)、resolution(100)、codec、manifest_file_id、created_at/updated_at | 转码任务;idx_transcode_status_update_at(status,updated_at,video_id,id) 支撑 watchdog |
video_manifest | video.VideoManifest | id PK、video_id、protocol(dash/hls)、file_id、profiles(jsonb)、status(ready/failed)、created_at/updated_at | 播放清单;表名单数,与 video_sources/video_transcodes 不一致 |
tags / video_tags | tag.Tag / tag.VideoTag | tags(id, name unique);video_tags(video_id+tag_id 复合主键) | 标签体系(目前没有任何 API 使用) |
video_comments | social.VideoComment | id PK、video_id、user_id、root_id、parent_id、reply_to_id、reply_to_uid、content、attachments(jsonb)、status(visible/pending/hidden/deleted)、like_count、reply_count、created_at/updated_at、deleted_at | 楼中楼三级指针;idx_comments_video_root(video_id,root_id,id) |
comment_likes | social.CommentLike | comment_id+user_id 复合主键、created_at | 评论点赞明细(异步落库) |
video_likes | social.VideoLike | video_id+user_id 复合主键、created_at | 视频点赞明细(复合主键天然去重) |
video_favorites | social.VideoFavorite | video_id+user_id 复合主键、created_at | 视频收藏明细 |
video_play_logs | social.VideoPlayLog | id PK、video_id、user_id、played_at、ip_address(inet)、user_agent | 播放流水;当前只写 id/video_id/played_at,user/ip/ua 留空 |
danmakus | danmaku.Danmaku | id PK、video_id(index)、user_id、content、time_offset(float,秒)、color、mode(0滚动/1顶部/2底部)、created_at | time_offset 没有索引;Kafka 消费按主键幂等 |
sensitive_words | danmaku.SensitiveWord | id PK、word unique、created_at | AC 自动机词表来源;增删触发全表重载 |
配置项速查
加载顺序:
-c <path>> 环境变量VISTACK_CONFIG_PATH> 默认conf/app.local.toml(core/vipper.go:56-73、config/config.go:139)。容器部署用VISTACK_CONFIG_PATH=/app/conf/app.docker.toml。
| 配置段 | 关键键 | 含义 / 默认值 |
|---|---|---|
[server] | host port mode web_dir admin_web_dir | HTTP 监听(0.0.0.0:8080)、gin 模式、静态托管目录(docker 下为 /app/web、/app/web-admin) |
[logging] | level | debug/info/warn/error(core/logger.go:14-26)。注意三份 conf 里都没有这个段,所以实际生效是 info |
[database] | host/port/user/password/name 或 dsn;max_idle_conns=10、max_open_conns=100、conn_max_lifetime=3600 | DSN 优先,否则按字段拼装(core/db.go:17-60) |
[redis] | host port db password pool_size=10 | host 为空则跳过初始化 |
[minio] | endpoint public_endpoint access_key secret_key secure bucket | 内网 endpoint 用于服务端读写,public_endpoint 用于浏览器直连与预签名 |
[auth] | kid=vistack-rs256 issuer=vistack jwt_expiration=3600 jwks_path=/.well-known/jwks.json | 签发方参数(auth 角色用) |
[auth_service] | http_addr=:8081 grpc_addr=:50052 jwks_url | api 侧拉 JWKS 与调 gRPC 用;缺省时 defaultJWKSURL 兜底成 http://127.0.0.1:8081/... |
[cache] | enabled default_ttl_min=300 default_ttl_max=600 null_ttl=60 lock_ttl=5 lock_wait_ms=2000 recommend_ttl=300 bloom_enabled bloom_bits=10000000 bloom_hashes=7 | 缓存三件套参数;enabled=false 时 core.Cache=nil,读路径直接回源 |
[ratelimit] | enabled algorithm=sliding_window token_rate=10 token_burst=20 window=60 limit=100 | 登录后接口限流 |
[social] | enabled flush_interval=5 flush_batch=200 leaderboard_size=50 | 点赞/收藏/播放计数 |
[danmaku] | enabled local_cache_size=1024 local_cache_ttl=2 cache_control_max_age=5 | 弹幕(注意 cache_control_max_age 目前未被使用,handler 硬编码 5s) |
[comment] | enabled flush_interval=5 flush_batch=200 | 评论点赞异步落库 |
[snowflake] | node_id=0 | 0 表示自动派生(app.local.toml 里仍是 1,本地单实例可接受) |
[kafka] | brokers group_id=vistack-consumer-group concurrency=4 | 每实例并发 reader 数(代码兜底为 1;实际受分区数限制) |
[cors] | enable allow_origins allow_methods allow_headers allow_credentials | 白名单式 CORS(middlewares/cors.go) |
[etcd] | endpoints prefix=/vistack/transcoders leader_ttl=10 | 服务注册 + 领导选举 |
[transcoder] | listen_addr=:50051 addr=localhost:50051 use_etcd=true | worker 发现模式与静态兜底地址 |
环境变量
| 变量 | 作用 |
|---|---|
VISTACK_CONFIG_PATH | 指定配置文件(容器内 /app/conf/app.docker.toml) |
VISTACK_<SECTION>_<KEY> | Viper AutomaticEnv + .→_:如 VISTACK_DATABASE_HOST、VISTACK_MINIO_BUCKET(core/vipper.go:23-25) |
VISTACK_AUTH_RSA_PRIVATE_KEY | 内联 PEM 私钥(\n 会被还原) |
VISTACK_AUTH_RSA_PRIVATE_KEY_FILE | PEM 私钥文件路径 |
POD_IP | k8s 注入,用于 etcd 注册地址与 snowflake node_id 派生 |
-c <path> / 首个位置参数 | 命令行指定配置路径 / 指定角色 |
⚠️ 两个需要「需确认」的细节:① Viper 的
AutomaticEnv配合Unmarshal时,配置文件里不存在的键不一定能被环境变量覆盖(Viper 已知行为),要用前最好实测;②web/*/vite.config.ts里打印的是VITE_API_URL,但客户端实际读的是VITE_API_BASE(web/ui/src/api/axios.ts:8),不要被日志误导。
已知缺口与可诚实承认的点
每条给一句「怎么答」。主动说缺口比被问出来强,但要说得具体、有改进方向、不贬低整体设计。
| # | 缺口(代码事实) | 怎么答 |
|---|---|---|
| 1 | 直播完全没实现:README 和弹幕 spec 都写了 live777(Rust SFU)/OBS 推流/WebRTC,但全仓库 grep -rni 'live777|webrtc|rtmp|whip' 在代码里零命中,没有推流密钥校验 | 「直播是我文档里规划的下一阶段,代码还没落地,目前交付的是完整的点播链路。我不想把没做的说成做了。」 |
| 2 | 无 Prometheus / OpenTelemetry:只有 zap 日志,没有 metrics、没有 trace、没有日志聚合 | 「排障时最痛苦就是看不到 Kafka 积压和转码成功率。这是我们自己 review 里排在 P1 的项(docs/specs/distributed-architecture.md:73-76)。」 |
| 3 | /health 恒返回 200,只报告依赖状态不做判定,且不分 readiness/liveness;k8s 清单里也没有探针 | 「它现在只能用来人肉看状态,不能当探针——因为失败也返回 200。正确拆法是 /healthz 永远 200 + /readyz 依赖不通返回 503。」 |
| 4 | 无 DLQ:重试 7 次后 markFailed/watchdog 直接 continue,静默丢弃且无告警 | 「这是明确的缺口。理想做法是超限消息进死信 topic,配告警和可重放工具。」 |
| 5 | 无 Outbox:CompleteVideoUpload 是「事务提交后再发 Kafka」,中间进程被杀会丢消息 | 「现在靠 watchdog 扫 pending 超 10 分钟兜底,用户最多等 10 分钟;严格场景应该上 outbox 表。」 |
| 6 | transcoder gRPC 无鉴权:grpc.NewServer() 无任何 interceptor,内网任意服务都能发起转码 | 「内网信任模型的简化,改进就是 mTLS(我们 roadmap 里已列),至少也要加一个 token interceptor。」 |
| 7 | etcd 单节点:compose 一个容器、k8s replicas: 1 且没有挂 volume(--data-dir=/etcd-data 是临时存储) | 「单节点只够开发;生产要 3~5 节点 + TLS。k8s 清单里 etcd 还缺 PVC,重启会丢注册数据。」 |
| 8 | Kafka 1 分区 1 副本:EnsureTopic 写死 NumPartitions:1, ReplicationFactor:1,导致 concurrency=4 实际只跑得起 1 个消费者 | 「这是我最想先改的一处:topic 提到 8~16 分区、副本因子 ≥2,消费并发才真正生效。」 |
| 9 | 无压测、无 QPS/延迟数据:README 与 specs 里没有任何 benchmark 数字 | 「我没有压测数据,所以不会给任何 QPS 结论。要说性能的话,我只能讲清架构上的并发点在哪(分片直传绕开 api、DASH 分片走对象存储)。」 |
| 10 | 测试覆盖很低:6 个测试文件、26 个 Test(cache / interaction / danmaku / ratelimit / web),没有集成测试、没有 CI 覆盖率门禁 | 「核心算法层(缓存、限流、AC 自动机、互动计数)有单测,链路级没有。要补的话优先补上传→转码的状态机测试。」 |
| 11 | snowflake node_id 用 FNV32a % 1024 派生,实例多时理论上会碰撞;pkg/snowflake 懒加载兜底还是 Init(1) | 「1024 个槽位对当前规模够用,但派生哈希不保证唯一。规模化应该改成 etcd CAS 分配。」 |
| 12 | 没有 AI 相关代码:全仓库没有任何 LLM/推理/向量相关实现 | 「没有就是没有。项目里跟内容相关的智能处理只有 AC 自动机关键词过滤,那不算 AI。」 |
| 13 | RBAC 只有表没有执行点:roles/authorities/role_authority/user_authority 都建了、注册时分默认角色,但代码里没有任何授权判定 | 「README 说的细粒度权限目前是目标。JWT 里也没有角色位,所以 api 侧现在只能做「登录与否」的判定。」 |
| 14 | /admin/sensitive-words 没有角色校验:只挂了 AuthMiddleware + 限流 | 「任意登录用户都能改敏感词表,根因同上(RBAC 没执行)。这是我会立刻补的一处。」 |
| 15 | k8s 清单不完整:没有 auth 的 Deployment/Service,没有探针、没有 resource limits、没有 HPA/Ingress/Secret,凭证明文写在 ConfigMap,且 ConfigMap 里还留着已废弃的 jwt_secret = "secret" | 「deploy/k8s 是脚手架级示例,不是生产可用清单。auth 那块是拆分后没同步补上的。」 |
| 16 | k8s 期望的 conf/app.k8s.toml 不存在(ConfigMap 现场生成,且缺 [auth_service] 段) | 「这会导致 api 在集群里退化去连 127.0.0.1:8081 拉 JWKS,实际跑不通——是清单与拆分改动脱节的典型例子。」 |
| 17 | 评论敏感词不生效:管理端加词只重建 danmaku 的自动机,评论服务的 filter 启动加载一次后再不刷新,审核 worker 里那个 service 词表还是空的 | 「这是真 bug,不是设计取舍。修法很简单:加词后同时通知两个 filter,或者把词表放到 Redis/pubsub 统一刷新。」 |
| 18 | 图片审核是桩实现:PassthroughModerator.Review 永远返回 true,SetModerator 全仓库无调用点 | 「带图评论会 pending → Kafka → 立即自动通过。留了 Moderator 接口就是为接第三方内容安全,但还没接。」 |
| 19 | Redis 队列弹出即丢:LPopCount 后若写库失败,这批事件已经没了(无回塞、无 DLQ) | 「『幂等可重试』只对重放同一批成立,对丢批不成立。改用 LMOVE 到 processing 列表或直接换 Kafka 就解决了。」 |
| 20 | 删除 worker 重投不幂等:没有已处理标记、不检查 video.status,TX2 里 3 条 Delete 的错误也没检查;ref_count - 1 无下限钳制 | 「TX1 提交后若 TX2/MinIO 失败,消息重投会把引用计数再减一遍。要加状态守卫(status=deleted 直接返回)和 GREATEST(ref_count-1,0)。」 |
| 21 | 大量 Redis key 没有 TTL:点赞/收藏/播放集合、两个榜单 ZSet、待落库 List、vistack:danmaku:* 都不会过期 | 「榜单没有时间窗、弹幕 ZSet 会随视频长度无限增长。需要加滑动窗口或定期归档裁剪。」 |
| 22 | 前端上传的重试无上限、无进度事件(fetch 无 Content-MD5/无 abort)、uploadId 不持久化(刷新即丢)、没有转码状态轮询 | 「体验层面的欠账:刷新页面就要重传,用户也看不到转码进度。改进点是 localStorage 存 uploadId + 轮询或 SSE 状态。」 |
| 23 | pkg/response 整包未使用(无任何 import);storage.UploadLocalFile、snowflake.GenStringID、core.GetMinioObjectPublicURL、auth.NewClaims、comment.SetModerator、UiDashPlayer 也都无调用点 | 「这些是重构后的死代码,应该删掉。被问到我会直接承认没做清理。」 |
| 24 | db/init.sql 不可用:没被任何 compose 服务挂载,且不是合法 PostgreSQL(MySQL 风格 COMMENT、缺分号、FK 指向 authority 而表叫 authorities),也没有索引 DDL,内容已落后于实体 | 「真实 schema 来自 GORM AutoMigrate,init.sql 是历史遗留,我该删掉或重写。」 |
| 25 | compose-build.yml + Dockerfile.build 已失效:编译的是仓库根本不存在的 ./main.go,且用 Go 1.25 镜像而模块要求 1.26 | 「这是最早期的交叉编译脚本,入口改名后没同步更新。我会删掉它,交叉编译改用 Makefile 或 goreleaser。」 |
| 26 | core.ValidateConfig 是空实现(_ = cfg);配置校验只剩 auth 角色启动时的私钥检查 | 「backend-hardening spec 里的 H1 是针对旧的 HS256 secret;换成 RS256 后这段校验被搬走了,函数留下成了空壳,应该删或补上真实校验。」 |
| 27 | 上传只校验大小不校验类型:File.go 三个上传口和视频上传都不看 MIME/魔数,SVG 可能造成存储型 XSS | 「已知问题,修法是 http.DetectContentType + 白名单 jpeg/png/webp,并在响应里加 Content-Disposition。」 |
| 28 | Traefik 无 TLS、dashboard insecure: true、没有定义任何 middleware | 「compose 里的 Traefik 面向本地开发;生产应由外部网关或加 443 entrypoint + 证书。dashboard 的 insecure 也要关掉。」 |
| 29 | 无版本化迁移与回滚:全部 AutoMigrate,只有 3 条手写 DDL(migrations/migrate.go:66-82) | 「AutoMigrate 不删列、不改类型,也没有 down 脚本。生产应该换 golang-migrate 之类带版本号的方案。」 |
| 30 | Kafka 消息是裸 JSON、无版本字段 | 「字段演进靠兼容不删改,跨版本灰度发布期可能解析失败。加 "v":1 或上 Schema Registry 都能解决。」 |
| 31 | internal/model/entity/audit 与 tag 是孤儿:audit_logs 迁移被注释(表不会建),tags/video_tags 建了表但没有任何 API 使用 | 「设计时留的扩展位,实际没接。被问到我会说是未完成而不是已完成。」 |
| 32 | compose.yml 里 VISTACK_ROLE 从未设置,全靠位置参数;另外 .env.example 里的 JWT_SECRET 是 HS256 时代的遗留键 | 「角色选择是双通道(环境变量或位置参数),清单用的是位置参数,功能正常但文档和实现有点脱节;.env.example 的 JWT_SECRET 现在没有任何代码读它,应该删。」 |
一句话自查清单(面试前扫一眼)
- 能立刻说出:5 个角色 / 4 个 topic / 3 条链路 / 2 个 proto。
- 能立刻画出:
浏览器 ↔ api ↔ (Kafka, Redis, MinIO) ↔ worker ↔ (etcd) ↔ transcoder。 - 能立刻报数:8MB 分片、6 并发、2MB 哈希块、4s DASH 分片、120 帧 GOP、25min gRPC 超时、30min lease、15/10min watchdog、7 次重试、8h 退避上限、10s 租约 / 3s 保活、300–600s 缓存、60s 空值、5s 锁、100 次/60 秒限流、50 榜单、200/5s 事件批、30min STS。
- 能立刻承认:直播没做、RBAC 没执行、无 metrics/DLQ/Outbox、Kafka 单分区、测试与压测都不足。