v1.0 已上线 · 2026-09-10

用兴趣认识人
用内容保持活跃
用实时通话沉淀关系

SoLink 是一款「内容 + 关系」双轮驱动的移动社交 APP——用兴趣匹配找到同好,用图文动态保持活跃,用实时 IM 与 1v1 音视频通话把关系沉淀下来。认识人、看内容、聊下去,闭环在同一款 APP 里完成。

Android · 约 86 MB Flutter 3.44 + Go 1.22 服务端 chat.yundudu.top 166+ 项端到端验证全绿

一、产品定位与功能

微信擅长维护熟人却难破冰,小红书内容好看却不负责建立关系,Soul 靠匹配但关系常断在第一次对话。

SoLink 把三段拼成一条链路:兴趣匹配负责认识人 → 图文 Feed负责保持活跃 → IM + 音视频负责沉淀关系。匹配成功即自动建立会话,不把用户丢在半路。

典型场景

刚到一个新城市的周五晚上

打开发现页,系统按兴趣重合度推送候选卡片,左右滑动十分钟就匹配到几个同好。双向喜欢后会话自动建立,页面直接给出基于共同兴趣生成的破冰话题。

周末拍了组照片想被看见

发布图文动态(最多 9 张图),内容进入关注流与推荐流。点赞、收藏、评论以通知形式聚合到通知中心,未读以角标提示。

打字说不清楚,直接开语音

从会话顶栏发起 1v1 语音或视频通话,对方收到全屏来电浮层,接听后音视频走端到端 P2P 直连,服务器只做信令牵线。

深夜躺床上刷内容

暗色模式 + 自定义主色调;加载有骨架屏、无内容有空状态、断网有明确错误态与重试入口。

六大亮点

1

三合一社交闭环

认识人、看内容、聊下去在同一款 APP 完成,匹配成功自动建会话,不把用户丢在半路。

2

真人级实时通讯

乐观更新 + 落库后真实 ACK,GREATEST 保证已读只前进;正在输入实时透传,2 分钟内可撤回。

3

兴趣驱动而非黑箱

1–12 个兴趣标签按重合度排序候选池,双向喜欢才建会话,附破冰话题,每日 50 次滑动配额。

4

通话:信令自控、媒体 P2P

信令走自研 WS + Redis 状态机,音视频媒体端到端直连、SRTP 加密,服务端一帧媒体都不经手。

5

治愈系设计体系

治愈蓝 #1890FF + 暖橙 #FF9D3F,支持暗色模式与 HSL 动态主题,主色可自定义并即时生效。

6

隐私优先的底层设计

手机号 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_channel3 个拦截器(token 注入 / 401 自动刷新 / 统一错误信封);自研心跳 + 指数退避重连 + 离线队列
客户端媒体flutter_webrtc1v1 音视频,媒体面 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,端口不暴露;崩溃自拉起、开机自启
选型上最重要的一条:没有引入消息队列、服务网格、微服务框架。当前规模下 Go 单进程 + MySQL + Redis 已能撑住全部闭环,运维复杂度几乎为零。拆分是"用户量到了再说"的事。

系统架构分层

客户端层 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 每条事件整体重建)。

消息可靠性:三条机制叠加

// 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 让这个字段天然免疫乱序。

会话幂等:member_hash = md5(min:max)

// 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[:])
}
核心思想:把「无序的两人关系」归一化成「有序的字符串」,再交给数据库唯一约束。 A 找 B 和 B 找 A 得到同一个 md5,正反向创建必然撞同一唯一键——不需要任何分布式锁

音视频通话架构

最关键的一句话:服务端只转发 offer / answer / ICE,音视频数据一帧都不经过服务器。
信令 Signaling媒体 Media
内容offer/answer SDP、ICE candidate、invite/accept/reject/hangup音频 RTP、视频 RTP
路径A ↔ 我们的 WS(Go 中转)↔ BA ↔ B 直连(P2P)
协议WebSocket(JSON 信封)SRTP over UDP(DTLS 加密)
数据量每次通话几十 KB每秒几百 KB ~ 几 MB
服务器成本转发即可若为 SFU 则是带宽黑洞

1v1 场景下 P2P 完全够用,代价是打洞失败的网络必须靠 TURN 兜底。服务端对 SDP 原样透传、不解析:解耦(SDP 格式随 webrtc 版本演进)、性能(ICE 一次通话几十条)、安全(不解析就不会因解析 bug 崩溃)。服务端只做两件事——校验你是会话成员、找到对端。

事件设计:单一 call.push + action

状态事件与媒体信令分成两条通道:call.push(生命周期状态,一次通话 3~5 条,不能丢)与 call.signal(媒体协商,几十条,丢几条 ICE 不影响)。混在一个事件里服务端每条都要判断"这是状态还是 SDP",高频 ICE 还会挤占状态事件处理路径。分成两个事件后 onSignal 只有 25 行纯转发,正确性极易验证。

通话状态机:Redis 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_recorduk_pair 防重复关注;拉黑阻断 Feed 与 IM;双向 like 生成会话
消息域conversation · conversation_member · messagemember_hash 唯一索引防重;未读/已读位置按人存client_msg_id 幂等键
通知域notification赞 / 评论 / 关注 / 匹配 / 系统五类

Redis Key 约定

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} 限流
一致性策略:所有写路径先保证 DB 事务成功;缓存是旁路,写失败只记日志不影响主流程;读路径缓存缺失/异常/为空一律回落 DB。好处是任何时刻 Redis 全挂,系统仍然正确,只是变慢

安全与隐私

手机号保护

AES-GCM 加密落库,密钥由配置派生不硬编码,12 字节随机 nonce 拼在密文前,GCM 自带完整性校验;另存 SHA256 哈希建唯一索引,兼顾可精确查找与不可反解。

令牌与鉴权

JWT 双令牌(Access + Refresh),每个令牌带独立 jti;登出按 jti 吊销,刷新走轮换语义。接口白名单支持方法限定(同路径 GET 公开、POST 仍需鉴权)。

WS 鉴权

协议升级前用 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 层单测。

下载 Android 客户端

安装后可用任意手机号注册体验,当前为开发模式,验证码固定为 123456(先点发送验证码再输入)。

下载 solink.apk
v1.0约 86 MBAndroid更新于 2026-09-10

下载链接:https://chat.yundudu.top/download/solink.apk

手机浏览器打开本页直接点击即可下载安装。若提示"未知来源",请在系统设置中允许来自该来源的应用安装(Android 8+ 会逐次授权)。