SoLink 是一款「内容 + 关系」双轮驱动的移动社交 APP——用兴趣匹配找到同好,用图文动态保持活跃,用实时 IM 与 1v1 音视频通话把关系沉淀下来。认识人、看内容、聊下去,闭环在同一款 APP 里完成。
微信擅长维护熟人却难破冰,小红书内容好看却不负责建立关系,Soul 靠匹配但关系常断在第一次对话。
打开发现页,系统按兴趣重合度推送候选卡片,左右滑动十分钟就匹配到几个同好。双向喜欢后会话自动建立,页面直接给出基于共同兴趣生成的破冰话题。
发布图文动态(最多 9 张图),内容进入关注流与推荐流。点赞、收藏、评论以通知形式聚合到通知中心,未读以角标提示。
从会话顶栏发起 1v1 语音或视频通话,对方收到全屏来电浮层,接听后音视频走端到端 P2P 直连,服务器只做信令牵线。
暗色模式 + 自定义主色调;加载有骨架屏、无内容有空状态、断网有明确错误态与重试入口。
认识人、看内容、聊下去在同一款 APP 完成,匹配成功自动建会话,不把用户丢在半路。
乐观更新 + 落库后真实 ACK,GREATEST 保证已读只前进;正在输入实时透传,2 分钟内可撤回。
1–12 个兴趣标签按重合度排序候选池,双向喜欢才建会话,附破冰话题,每日 50 次滑动配额。
信令走自研 WS + Redis 状态机,音视频媒体端到端直连、SRTP 加密,服务端一帧媒体都不经手。
治愈蓝 #1890FF + 暖橙 #FF9D3F,支持暗色模式与 HSL 动态主题,主色可自定义并即时生效。
手机号 AES-GCM 密文 + SHA256 唯一索引,JWT 双令牌 + 黑名单,全站 HTTPS/WSS,SQL 全参数化。
| 模块 | 一句话描述 | 关键能力 |
|---|---|---|
| 即时通讯 | WebSocket 长连接单聊系统 | 已读回执 · 正在输入 · 2 分钟撤回 · 未读角标 · 离线同步 · 幂等去重 · ACK 回填 |
| 动态 Feed | 图文双 Tab 信息流 | 发布(≤9 图)· 关注流 · 推荐流 · 点赞 · 收藏 · 评论 · 游标分页 · 热点缓存 |
| 陌生人匹配 | 兴趣驱动卡片滑动匹配 | 兴趣标签 · 喜欢/跳过 · 双向喜欢自动建会话 · 破冰话题 · 配额 50/日 · 实时推送 |
| 音视频通话 | 1v1 语音/视频 | 语音/视频呼叫 · 来电浮层 · 信令状态机 · offer/answer/ICE 透传 |
| 兴趣社区 | 以兴趣标签组织用户 | 标签选择(1–12 个,预置 20 个)· 共同兴趣计算 · 基于重合度推荐 |
| 通知中心 | 全站互动统一收件箱 | 点赞 · 评论 · 关注 · 匹配 · 系统 · 未读角标 · 已读独立管理 |
| 个人中心 | 形象管理与外观偏好 | 资料编辑 · 主题模式 · 品牌色自定义 · 通知开关 · 退出登录 |
主色治愈蓝 #1890FF(暗色提亮为 #4D8EFF),互动色暖橙 #FF9D3F;9 阶中性灰 + 4 类语义色。9 级字号(10–24px),间距基于 4px 基准、8 点网格。
5 档圆角:4 / 8 / 12 / 16 / 999px。暗色模式默认跟随系统、过渡 250ms,非简单反色,全量 Token 重校对比度。
每个页面交付默认 / 加载骨架 / 空状态 / 网络错误四套状态,再叠加业务专属态(正在输入、已撤回、额度耗尽)。
遵循 WCAG AA:正文对比度 ≥ 4.5:1,触控区 ≥ 44×44px,支持系统字号 200% 缩放,遵循 prefers-reduced-motion,状态不依赖单一颜色传递。
模块化单体 + 单一 WebSocket 通道,用最低的运维复杂度跑通完整社交闭环。
| 层 | 技术 | 选型理由 |
|---|---|---|
| 客户端 | Dart 3 / Flutter 3.44 · Riverpod · GoRouter | 编译期安全的状态管理,契合 feature-first 三层拆分;声明式路由 + 登录态重定向守卫 |
| 客户端网络 | dio + web_socket_channel | 3 个拦截器(token 注入 / 401 自动刷新 / 统一错误信封);自研心跳 + 指数退避重连 + 离线队列 |
| 客户端媒体 | flutter_webrtc | 1v1 音视频,媒体面 P2P 直连 |
| 服务端 | Go 1.22 · Gin · GORM · gorilla/websocket · go-redis/v9 · golang-jwt/v5 | 路由注册期冲突直接 panic,等于免费的一致性检查;读写泵模型成熟 |
| 存储 / 缓存 | MySQL 8(12 表)· Redis | 强一致权威源 + 用 TTL 换定时任务(call:state 是典型例子) |
| 接入 / 守护 | Nginx(TLS 终结 + 反代)· systemd | 后端只监听 127.0.0.1:8901,端口不暴露;崩溃自拉起、开机自启 |
客户端层 Flutter(页面 / Riverpod 状态 / Dio REST + WsClient 单连接)
│ HTTPS REST │ WSS
▼ ▼
接入层 Nginx(chat.yundudu.top:443)
├─ /api/* · /uploads/* · /health → 反代到应用层
└─ /ws(Upgrade 反代 → WSS)
▼
应用层 Go 模块化单体(127.0.0.1:8901)
├─ 中间件链:Recovery · Logger · CORS · RateLimit · JWT
├─ internal/ws:Hub 连接中心 + Client 读写泵
└─ 12 个业务模块:auth · user · feed · like · comment · im
match · webrtc · notify · relation · topic/search · misc
▼
数据层 MySQL 8(库 solink · 12 表) + Redis(在线 · 未读 · 通话状态 · 限流)
服务端 Hub 维护 map[uint64]*Client,一个用户一条连接(同用户重复连接顶替旧连接)。同一条 WS 被 im / webrtc / notify / match 四个模块共用,靠信封 event 字段路由。信封的 Payload 是 json.RawMessage——基础层只解到 event 一层,payload 由业务模块自己反序列化,所以新增事件类型 Hub / Client 一行都不用改。
连接活性由读写泵保证:两个 goroutine 任一退出即注销(gorilla 的 *Conn 要求同一时刻只能有一个读者、一个写者)。下行投递非阻塞,队列 256,慢消费者直接断开而不是拖垮广播。客户端侧对称收敛为一条连接,事件用 broadcast Stream 暴露给 N 个订阅者,用 ref.listen 而非 ref.watch(后者会让 Provider 每条事件整体重建)。
(conv_id, client_msg_id)。针对真实重放场景:消息已落库推送但 ACK 回程丢包 → 客户端重连后重放离线队列 → 无幂等则对方收到两条一样。msg.ack 的 serverMsgId 是落库后的服务端 ID,协议层禁止 pending 假回执。幂等冲突同样返回成功 ACK,只携带原消息 ID。// backend/internal/module/im/repo.go:189 —— 单事务三写
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1) 写入消息;uk_conv_client 冲突 → 幂等重复投递,事务内无任何写入
if err := tx.Create(m).Error; err != nil {
if isDuplicateKeyErr(err) {
return nil // created 保持 false,由服务层回查已有消息
}
return err
}
created = true
...
return nil(提交)而不是 return err(回滚):插入本来就失败了,没什么可回滚,标记 created=false 后正常退出,再由服务层回查已有消息。未读存在 conversation_member 上而非 conversation 上——同一会话里 A 和 B 的未读数天然不同。策略是 DB 为准、Redis 加速、漂移自愈:发送时 DB 随事务 +1,Redis HINCRBY 只是加速(Redis 异常返回 -1,调用方降级读 DB);每次拉会话列表时顺手比对并回填修正——没有独立的缓存一致性任务,读的时候顺手修。
-- backend/internal/module/im/repo.go:340 —— GREATEST 保证已读位置单调不减
UPDATE conversation_member
SET unread = 0,
last_read_msg_id = GREATEST(last_read_msg_id, ?)
WHERE conv_id = ? AND user_id = ?
客户端上报已读是频繁且乱序的(每收一条就上报、上滑看历史先上报 500 再上报 480、多端位置不同)。直接赋值会让一次乱序旧值把已读位置"倒退",已读过的消息又变回未读。GREATEST 让这个字段天然免疫乱序。
// backend/internal/module/im/model.go:92
func SingleMemberHash(a, b uint64) string {
lo, hi := a, b
if lo > hi { lo, hi = b, a }
sum := md5.Sum([]byte(fmt.Sprintf("%d:%d", lo, hi)))
return hex.EncodeToString(sum[:])
}
| 信令 Signaling | 媒体 Media | |
|---|---|---|
| 内容 | offer/answer SDP、ICE candidate、invite/accept/reject/hangup | 音频 RTP、视频 RTP |
| 路径 | A ↔ 我们的 WS(Go 中转)↔ B | A ↔ B 直连(P2P) |
| 协议 | WebSocket(JSON 信封) | SRTP over UDP(DTLS 加密) |
| 数据量 | 每次通话几十 KB | 每秒几百 KB ~ 几 MB |
| 服务器成本 | 转发即可 | 若为 SFU 则是带宽黑洞 |
1v1 场景下 P2P 完全够用,代价是打洞失败的网络必须靠 TURN 兜底。服务端对 SDP 原样透传、不解析:解耦(SDP 格式随 webrtc 版本演进)、性能(ICE 一次通话几十条)、安全(不解析就不会因解析 bug 崩溃)。服务端只做两件事——校验你是会话成员、找到对端。
状态事件与媒体信令分成两条通道:call.push(生命周期状态,一次通话 3~5 条,不能丢)与 call.signal(媒体协商,几十条,丢几条 ICE 不影响)。混在一个事件里服务端每条都要判断"这是状态还是 SDP",高频 ICE 还会挤占状态事件处理路径。分成两个事件后 onSignal 只有 25 行纯转发,正确性极易验证。
call:state:{convId}// backend/internal/module/webrtc/signal.go:38
const (
callStateKeyPrefix = "call:state:"
inviteTTL = 60 * time.Second // 呼叫等待窗口(超时自动清理)
inCallTTL = 2 * time.Hour // 通话中保底窗口(防状态残留)
)
两个 TTL 差这么大是有意的:呼叫中 60s = 振铃时长,60s 无人接听 Key 自动消失,状态回到空闲,不需要任何定时任务清理;通话中 2h 不是"最长通话 2 小时",而是防状态残留的兜底(App 被强杀、手机没电后 2h 自动消失,避免该会话永远打不进)。用 TTL 代替清理任务——Redis 原生能力,零代码,不会漏。
三种并发/异常:同一主叫重复 invite → 幂等重发并刷新 TTL;他人正在呼叫 → 新主叫收 busy;被叫离线 → 主叫收 offline 且刻意不写 Redis(写了状态,被叫上线后最长 60s 内谁都打不进来)。挂断时若客户端没传 duration,服务端用 acceptedAt 自己算——客户端时钟不可信。
webrtc 需要 im 的能力(校验成员、查对端),im 需要把 call.* 上行信令转交 webrtc——互相 import 会触发 Go 编译期 import cycle。解法是两个接口各自定义在使用方自己的包里:
// backend/internal/app/app.go:169 —— 装配层同时 import 两者并接线
webrtcRelay := webrtc.NewRelay(hub, imSvc, userSvc, redisCache, ...)
// call.* 上行信令从 im WS 分发器转投 webrtc(两阶段注入,防 import 环)
a.IMWSHandler.SetCallDispatcher(webrtcRelay)
im.CallDispatcher(定义在 im,由 webrtc.Relay 实现)+ webrtc.IMGateway(定义在 webrtc,由 im.Service 实现)。两包互不 import,整套"两阶段装配 + 构造注入"让应用零全局单例,单测可自由替换依赖。12 张核心表 + 9 类 Redis Key,统一"DB 为准 → 缓存加速 → 降级兜底"。
| 域 | 表 | 关键设计 |
|---|---|---|
| 用户域 | user · user_interest | 手机号 AES-GCM 密文 + SHA256 唯一索引;uk_user_tag 为匹配核心维度 |
| 内容域 | post · comment · interaction | 媒体/话题用 JSON;冗余 like/comment/collect 计数;(user,target,type,action) 唯一键保证点赞幂等 |
| 关系域 | follow · block · match_record | uk_pair 防重复关注;拉黑阻断 Feed 与 IM;双向 like 生成会话 |
| 消息域 | conversation · conversation_member · message | member_hash 唯一索引防重;未读/已读位置按人存;client_msg_id 幂等键 |
| 通知域 | notification | 赞 / 评论 / 关注 / 匹配 / 系统五类 |
auth:sms:{phone} 验证码 · 5min
auth:token:blacklist:{jti} 令牌吊销
im:unread:{userId} 未读 Hash
im:typing:{convId}:{userId} 正在输入
call:state:{convId} 通话状态机
notify:unread:{userId} 通知未读
feed:hot:{type} 热点缓存
match:pool:{userId} 候选池
rate:{path}:{ip} 限流
AES-GCM 加密落库,密钥由配置派生不硬编码,12 字节随机 nonce 拼在密文前,GCM 自带完整性校验;另存 SHA256 哈希建唯一索引,兼顾可精确查找与不可反解。
JWT 双令牌(Access + Refresh),每个令牌带独立 jti;登出按 jti 吊销,刷新走轮换语义。接口白名单支持方法限定(同路径 GET 公开、POST 仍需鉴权)。
协议升级前用 query token 鉴权,非法请求连 101 都拿不到。用 query 而非 Header,因为浏览器 new WebSocket() 无法自定义 Header。
全站 HTTPS/WSS;上传扩展名白名单 + 只保留 basename 防路径穿越;SQL 全参数化;IP + 路径维度限流(默认 100 次 / 60 秒)。
| 类别 | 数量 | 说明 |
|---|---|---|
| 后端端到端冒烟 | 约 164 项 | 登录 27 + Feed 44 + IM 24 + 匹配/WebRTC/通知 30(合计 125),叠加 QA 独立验收 39 项 |
| WS 探针 | 19 项 | 双账号双连接实测消息/已读/撤回/通知/匹配/通话信令全流程 |
| 前端单元测试 | 41 个 | 路由守卫 · 模型契约 · 通话状态机;flutter analyze 0 issue |
| 跨端契约对齐 | 54 项 | REST 端点 · WS 事件名 · JSON 字段映射三方比对 |
统一响应信封 {code, message, data},错误码按业务分段:1xxx 参数 / 2xxx 业务(2101 验证码错误、2401 配额耗尽、2701 撤回超时)/ 3xxx 鉴权 / 4xxx 资源 / 5xxx 服务。
Flutter App → HTTPS/WSS(chat.yundudu.top:443)→ Nginx 反代 → systemd 守护的 Go 服务(127.0.0.1:8901)→ MySQL 8 + Redis。后端为静态编译二进制(约 13MB,stripped),只监听回环地址,端口不对外暴露。
http2 on(含 h3/quic)时,浏览器在 h2 下不发送 Upgrade 头,Nginx 收到 h2 的 WebSocket 请求直接返回 400,表现为 IM 与通话信令全部连不上。解决办法是在该站点关闭 http2/http3/quic,让 TLS 走 HTTP/1.1,验证期望得到 HTTP/1.1 101 Switching Protocols。诚实标注——这些是"已知且可改进项",而非设计缺陷。
| 项目 | 状态 |
|---|---|
| 产品阶段 | v1.0 核心闭环完成,处于公开可体验的最小可行产品阶段 |
| 后端 | 2026-09-10 已部署上线(Nginx + systemd 守护 + MySQL + Redis) |
| 客户端 | Flutter 全栈交付,11 个业务模块页面完成;analyze 0 issue,41 项单测全过 |
| 已跑通闭环 | 登录 · Feed · IM · 匹配 · 音视频 + 通知,共五大闭环 |
| 限制 | 说明 |
|---|---|
| 音视频在复杂网络下可能连不通 | 当前 ICE 仅配置 STUN,TURN 配置项已预留但未启用。对称 NAT / 企业网 / 运营商 CGNAT 下 P2P 打洞会失败(业界统计约 8%~20%) |
| 离线推送尚未接入 | 目前只有 WebSocket 在线推送,未接入 APNs / FCM / 厂商通道。APP 在后台或被杀进程时新消息、来电无法触达 |
| 仅支持单聊,群聊未开放 | 数据模型已按群聊设计,业务层只开放单聊,相关接口返回"功能开发中" |
| 发布仅支持图文 | 支持文字 + 最多 9 张图片,视频发布与沉浸流尚未实现 |
| 评论层级为两层 | 支持一级评论与二级回复,不支持更深层嵌套 |
| 媒体存于本地磁盘 | 已抽象上传接口但尚未切换对象存储(OSS/COS),不适合大规模生产扩容 |
| 搜索与话题系统为占位 | 全局搜索、话题广场等 P1 功能尚未实现 |
| 音视频媒体面待真机全链路验证 | 信令流已由 13 项状态机单测与 WS 探针覆盖,实际渲染需在真机完整验证 |
已明确的迭代清单:接入 TURN 中继 · 接入厂商推送通道 · 切换对象存储 · 开放群聊 · 上线搜索与话题系统 · 补 service 层单测。
安装后可用任意手机号注册体验,当前为开发模式,验证码固定为 123456(先点发送验证码再输入)。
下载 solink.apk下载链接:https://chat.yundudu.top/download/solink.apk