| 档案 | |
|---|---|
module |
community |
summary |
社群与频道:社群 = 一个公告频道 + N 个群 + 一份显式成员表,不进 conversations;建社群一个事务做六件事;挂群 = 群里在册的人整批进社群(公告 / 大厅里已退出的旧成员行只有社群自己的 join / addMembers 才恢复,挂群吸收和经子群进来的都不恢复);主动退社群必须先退光非 General 群,被管理员移出则只退公告 + 大厅、其他子群留着。群三层角色、四样两样设置、发言闸三态;可见性单向锁(members → community 拒);入群闸 202 待批,管理员申请只有 owner 批;社群层审批队列没做,凭链接进非 open 社群吃 403。官方频道注册自动进。频道只读门在落库咽喉(角色查询正常时才拦,查询报错不拦、交给后面的 INSERT)。入群 / 管理员申请的「批准」和「真入群 / 真升职」是两步,不是一笔事务。两套邀请链接同形状、无过期无次数上限。 |
updated |
2026-09-08 |
verified_ref |
9e9a78967211a069199417acfa9ec3e79669bd1a |
source_ref |
[backend/internal/domain/community, backend/internal/domain/conversation(群 / 频道那半), backend/internal/domain/message(发言闸只引), app/lib/screens/communities_screen.dart, app/lib/screens/community_.dart, app/lib/screens/group_info_screen.dart, app/lib/screens/group_join_requests_screen.dart, app/lib/screens/group_preview_screen.dart, app/lib/screens/invite_link_screen.dart, app/lib/screens/new_channel_screen.dart, app/lib/widgets/community_.dart, app/lib/widgets/channel_*.dart, app/lib/widgets/member_action_sheet.dart, app/lib/data/local_store.dart] |
status |
current |
review_after |
2026-12-07 |
owner |
nova |
supersedes |
[COMMUNITY.md, COMMUNITY_PERMISSIONS.md] |
partially_supersedes |
[CHANNEL_ROOMS.md(只吸收「公告频道是社群零件」「只读门」两条,频道房型本体留给 rooms), WORK_ROOMS.md(只补「工作房可挂进社群」一句), CHANNEL_CONTENT.md(不吃,归内容 / 增长线)] |
这份怎么读:每一节先讲「它解决什么、用户看到什么」,再讲「为什么这样做、数据在哪、哪一步会断、断了留下什么、有什么保障和缺口」;代码位置、参数、行号收在每段末尾的「出处」里。 出处标记:
[code@9e9a7896 文件:行]= 那棵树上的实现;[prod@日期,谁核]= 生产机当天核过;[decision@日期,谁]= 拍板过的决定。代码证明「那一版怎么做的」,证明不了「线上是这样」和「应该是这样」。 文件名为什么不是 community.md:旧图纸docs/COMMUNITY.md和它只差大小写,macOS 不分大小写 —— 第一版就因此把正文写进了旧文件、提交里没有正文(Gode 09-08 抓到)。本文叫community_module.md,旧图纸原样留着。 边界:群里发消息的闸序归docs/chat.md(本文只写闸是什么);踢人后通话怎么断归docs/calls.md;工作房 / 幽灵房 / 狼人杀的生命周期归 rooms;官方频道的内容排期归内容线。
0. 给接手的人:三分钟读懂社群与频道
先认五个词:社群(community) = 一栋楼:一个公告频道 + 若干群 + 一份自己的成员表,它本身不是会话;General(大厅) = 建社群时自动生成、删不掉的默认群,成员行由各入口补建(退出过的行不一定被恢复,见下面第 1 条);频道(channel) = 第七种会话,正常权限查询下只有 owner / admin 能发,其他人只看(可以点表情,不能发贴纸);分栏(section / 列表) = 社群主页上群的分组,每个社群至少一个、删不掉、可改名;吸收(absorb) = 一个群挂进社群时,群里在册的人整批变成社群成员,并补建大厅和公告的成员行(已退出的不恢复)。
它保证到什么程度:
1. 进了社群就在大厅和公告里 —— 成员行都会补建,但「退出过的行要不要恢复」按入口分:
· 社群自己的 join、社群 addMembers → 写 community_members,公告 / 大厅的成员行恢复(revive=true,清 left_at);
· 挂群吸收 → 写 community_members,公告 / 大厅只补建缺的行,已退出的不恢复(DO NOTHING);
· 经关联子群进来(被加进子群、子群入群申请被批)→ 目标子群自己的成员行会恢复(AddMemberSQL),连带写 community_members,但公告 / 大厅同样只补建、不恢复(AbsorbCommunityRoomsSQL,DO NOTHING);社群层的审批没做,不存在「社群申请被批」这条路。
所以「每个成员都在大厅」只对前一种入口成立。
反过来「还在群里 = 还在社群里」是 08-21 定的方向、存量补过,但它只管主动退出:被管理员移出社群的人只被退出公告 + 大厅,还留在其他子群里 —— 这是设计,不是吸收失败也不是脏数据。
2. 自己退社群不会留下半截:必须先退光社群里的非 General 群,否则 409 并回那几个群的清单;手机端拿到清单让你逐个退。(被管理员移出走的是另一条路,见第 1 条。)
3. 权限只能往下收,不能悄悄放宽:群挂进社群后的可见性只允许 community → members(收紧),反向 400 visibility_locked —— 群里的人是在那个前提下说的话(Jeff 08-16 定)。社群 owner 不能被移出、不能从任免接口改角色。
4. 入群和当管理员都有审批队列,但「批准」和「真入群 / 真升职」是两步:开了「批准新成员」的群,凭链接或自助加入都是 202 pending,管理员批;「申请当管理员」只有 owner 能批。批准先把申请改成 approved、再去加成员 / 升职;后一步失败接口回 500,申请却已经不是 pending,再批一次是 409 —— 会留下中间态:入群那条可能是没进群,也可能已进子群但社群成员表 / 公告 / 大厅同步不完整(加成员不是一笔事务,见 §1.2);升职那条是没升。有没有补偿待核(§4)。社群那一层的审批队列没做,非 open 社群凭链接进来一律 403(§4 第一行)。
5. 频道里非 owner / admin 发不进 —— 前提是角色查得出来:只读门在消息落库那一刻,不是界面上藏个按钮;但它只在「类型 / 角色查询正常」时拦,查询报错就放过去、交给后面的 INSERT 自己失败或成功。通话那边是另一段代码、另一种失败策略(类型查询正常且是频道时才查角色,角色查不出当非管理员拒;类型查询报错不走这条拒绝分支),别把两边当同一道门。
当前明确没做的(§4 全表,先说三条):社群层的加入审批和「拿着链接算不算被邀请」;社群 / 频道的搜索与发现页(频道只有一个「发现频道」区,社群没有);两套邀请链接都没有过期时间和使用次数(000214 里带期限的那张表在 Go 里零引用)。
出了问题先查哪:§5 —— 「人在群里却不在社群」先看 community_members 和 community_rooms 视图;「加不进去」先看是 202(待批)、403(社群非 open / 无权)还是 400(可见性方向反了);「频道发不出」看 channelGate。
1. 两段流程的一生
1.1 建一个社群,把人拉进来
你看到的:「新建 → 社群」先看一页说明和示例,点开始 → 起名、描述、传头像 → 建好后社群主页上已经有「公告」和「大厅」两行、一个叫 Groups 的列表,聊天里出现两张欢迎卡;从好友里拉人时会先提示「他会同时进公告频道」;拉进来的人在会话列表看到一条社群行,和聊天混排,长按能置顶 / 静音 / 归档。
背后发生的:
1. 谁能建:所有人;adminUIDs 白名单只决定挂不挂「官方」徽章。手机端建社群把 join_policy 写死成 open —— 因为 P1 没有审批界面,设成 approval 等于把门焊死(§4)。
2. 一个事务六件事:建 channel(公告)+ 把 owner 写进去、communities 行、community_members 的 owner、建 General 群(type=group)、建默认分栏 Groups(position 0)、community_groups(is_general=true, section_id);再写两张卡(cmty_welcome / cmty_general)。头像是建完再传,传失败不回滚。为什么公告复用 channel、社群不进 conversations:社群自己没有消息流,只是一个壳(Jeff 08-12 拍板的三条判据)。
3. General 天生置顶,公告故意不置顶(Jeff 08-19「都做干净」)。
4. 拉人:POST /communities/{id}/members,谁能拉由社群层 who_can_add_members(all / admins,默认 admins)决定;写 community_members 并进两间屋子(community_rooms 视图 = 公告 ∪ General)。
5. 挂群 / 吸收:把一个已有的群挂进来(挂群的权限由 who_can_create_groups 管:all / approval / admins,默认 admins;approval 走推荐队列,管理员批),挂进来那一刻群里在册(left_at IS NULL)的人整批写进社群成员表并进两间屋子 —— 两句 INSERT 都是 ON CONFLICT DO NOTHING:已经是社群成员的不动,曾退出公告 / 大厅的旧成员行不复活(和第 4 步社群 addMembers 的 revive=true 不同;注释说是因为批量时没人替某个具体的人做决定。经子群进来的那条路也不恢复,见 §0 第 1 条)。反过来往社群里的群加人,也同步吸收。一个群只能属于一个社群(唯一索引)。频道类型不能挂进社群。工作房可以挂(v0.543)。
6. 会话列表怎么画:社群不是会话,单独从 /v1/me/communities 拉,按时间和普通会话混排;已挂进社群的群从聊天列表收起来(判据是服务端 community_id);社群行长按是另一张菜单(置顶 / 静音 / 归档,只要求是成员)。折叠发生在社群主页的列表卡上,存「收着的那几个 id」。
哪一步会断、留下什么:第 2 步任一子步失败 = 整个事务回滚,什么都没建;头像失败 = 社群在、没头像;第 4 步无权 = 403;第 5 步群已属别的社群 = 唯一索引拒;第 5 步吸收成功但某人曾退出过大厅 = 他在社群名单上、不在大厅里(不是 bug,见 §0 第 1 条);approval 档 = 202,群建了但没挂进社群(手机端单独一句文案);第 6 步社群列表拉失败 = 手机上「一片空白」曾被读成「你没有群」(v1.893 修的)。
出处:谁能建
[code@9e9a7896 backend/internal/domain/community/community.go:37-43](官方徽章:357);六件事事务:329-432,两张卡[code@9e9a7896 backend/internal/domain/community/cards.go:38-42];表[code@9e9a7896 backend/migrations/000214_communities.up.sql:1-85],community_rooms视图 000254,General 置顶 000258;拉人community.go:104-105,加人策略[code@9e9a7896 backend/internal/domain/community/create_policy.go:51-73](默认见 000284 / 000290);吸收(两句DO NOTHING)[code@9e9a7896 backend/internal/domain/community/absorb.go:35-54,81-88],revive=true只在社群 join / addMembers 两处community.go:896,1180[code@9e9a7896 backend/internal/domain/community/rooms.go:29-45];经子群进来:目标群自身恢复、社群屋子DO NOTHING[code@9e9a7896 backend/internal/domain/conversation/join_tx.go:56-58,73-81,107-115],反向吸收[code@9e9a7896 backend/internal/domain/conversation/join_tx.go:62-81,107-116];频道不能挂community.go:290-296;一群一社群唯一索引 000214;手机建社群[code@9e9a7896 app/lib/screens/communities_screen.dart:232,392-422](join_policy:'open':410),会话列表混排与收起[code@9e9a7896 app/lib/screens/conversations_screen.dart:403,434,580,612,1038,1118],拉人先确认[code@9e9a7896 app/lib/screens/communities_screen.dart:2167-2251],折叠键[code@9e9a7896 app/lib/screens/community_groups_screen.dart:112-125];空白误读 v1.893[decision@2026-08-12,Jeff:社群三条判据、「我们可不是为游戏而做的」][decision@2026-08-18,Jeff:进社群 = 进 General 和公告][decision@2026-08-21,Jeff:还在群里 = 还在社群里]。
1.2 凭链接进群、进社群:200 / 202 / 403 各是什么
你看到的:扫码或点链接 → 先看到一页预览(名字、类型、人数)→ 点加入 → 三种结局:直接进了;「已提交,等管理员批准」;或者「加入失败」(社群那边现在只有这一句,没说是要审批)。群主那边:群信息页里「入群申请」一屏两条队列(入群 / 申请当管理员),批一个少一个,批重复了会提示已处理。
背后发生的:
1. 两套链接逐字对称:群 …/g/{code}(表 conversation_invite_links),社群 …/cm/{code}(表 community_invite_links);base62、22 位;每个会话 / 社群同一时刻只有一条未撤销(部分唯一索引);重置 = 撤销旧的建新的;没有过期时间、没有使用次数上限。看链接 / 重置:群按「能不能加人」判,社群只放 owner / admin;私聊没有链接。
2. peek 公开(不鉴权):回标题 / 类型 / 人数(社群回 group_count / 头像)。
3. 群的 join:只允许 group / channel;已是成员回 already_member;频道 = 直接关注;群看 approve_new_members —— 关着直接 200 进,开着写 join_requests(同一人只一条 pending)回 202 {"status":"pending"},并给全部管理员发通知。
4. 社群的 join:join_policy != 'open' 一律 403 needs_approval —— 社群层没有审批队列,「拿着链接算不算被邀请」待 Jeff 拍板;open 的走同一条 joinResolved。
5. 批 —— 两步,不是一笔事务:列 / 批只放 admin;DecideJoinRequest 直接在连接池上把申请 UPDATE 成 approved / rejected(重复处理 409 already_decided),然后处理器先收通知、再调 AddMembers 加成员(目标群自己的成员行会恢复;群若挂在社群里,公告 / 大厅只补建缺行、已退出的不恢复)、写系统消息 + 成员流水 + 铃铛。AddMembers 本身也不是一笔事务:先用 SendBatch 写目标子群的成员行,批处理结束后才调 absorbIntoCommunity,后者再分两句 pool.Exec 写 community_members 和公告 / 大厅成员行。所以「加成员失败 → 接口 500」时人可能没进群,也可能已进了子群、甚至已进社群成员表、只剩两间屋子没同步;申请都已是 approved,再批一次吃 409,队列里也没他了。手机端把 409 当场把那行拿掉。
6. 申请当管理员:非成员 403、已是管理员 409、否则 202 pending;列 / 批只有 owner;同样两步:DecideAdminRequest 先写 approved,再 SetRole 升 admin + 流水 + 系统消息,升职失败一样留下「已批准、没升」。
7. 手机端状态机是纯函数:joinPhaseForStatus —— 200 继续解 body、202 单独一态 pending、404 gone、其余 error(所以社群的 403 落进「加入失败」,没有「需要审批」文案);群预览页 pending 时绝不跳聊天页。
哪一步会断、留下什么:链接被重置 = 老码 404;群开了审批 = 202,人还在门外,join_requests 有一行;社群非 open = 403,什么都没记;批准后执行失败 = 申请 approved、再批 409;入群那条人可能没进群、也可能已进子群但社群 / 公告 / 大厅同步不完整;升职那条是没升(SetRole 一句,不套用上面的多步),可核对实际成员 / 角色后,由有权限者尝试直接加人 / 直接任命补办;其他恢复路径待核(§4、§7);申请被批后没有 WS 实时角标,要重进页面才看到(§4)。
出处:群链接
[code@9e9a7896 backend/internal/domain/conversation/invite_link.go:104-107,121-124,157-166,172,177-193,215-249,263-266],表 000239;社群链接[code@9e9a7896 backend/internal/domain/community/invite_link.go:112-115,186,191-211,242-245],表 000288,join 复用community.go:860-905,非 open 403:874-882;入群闸[code@9e9a7896 backend/internal/domain/conversation/join_gate.go:57-63,78-89,170-177,202-230,238-300](批准两步:170-177改状态、:291-300才加人;AddMembers批 + 独立吸收[code@9e9a7896 backend/internal/domain/conversation/repository.go:605-629,648-662]);管理员申请[code@9e9a7896 backend/internal/domain/conversation/admin_request.go:75,107-175](:75先写 approved,:166-175才升职)(表 000308);手机 peek / join / 状态机[code@9e9a7896 app/lib/screens/invite_link_screen.dart:26-29,84,190,234,593,638-684],预览页[code@9e9a7896 app/lib/screens/group_preview_screen.dart:257-289],队列页[code@9e9a7896 app/lib/screens/group_join_requests_screen.dart:46,67,88,239-258],申请当管理员[code@9e9a7896 app/lib/screens/group_info_screen.dart:1258-1288][decision@2026-08-16,Jeff:「1-4 跟你建议做」→ 新人审批][decision@2026-08-24,Jeff:社群也要二维码][decision@2026-08-28,李敏:request to be group admin]。
2. 用户能看到的能力(怎么用 / 限制 / 入口)
| 能力 | 怎么用 / 限制 | 后端入口 | App 落点 |
|---|---|---|---|
| 建社群 | 所有人;建完自带公告 + 大厅 + 一个列表;join_policy 写死 open |
POST /v1/communities |
community_intro_screen → CreateCommunityScreen |
| 社群信息 | 头像 / 名 / 描述 / 群组 / 影音 / 星标 / 限时 / 两条权限 / 退出 / 举报(多数行操作的是公告频道) | GET /v1/communities/{id}、PATCH …/settings、POST …/avatar |
community_info_screen |
| 社群成员 | 成员墙、最近常联络排序、点人分三条路(自己 / 好友 / 陌生人 → 资料页);长按设 / 撤管理员、转让、移出(标红多选) | GET/POST …/members、PATCH/DELETE …/members/{uid}、POST …/transfer |
community_members_bar/open/sheet |
| 社群权限 | 谁能建群(all / approval / admins)+ 谁能加人(all / admins),owner / admin 改 | PATCH …/settings |
community_group_policy_screen、community_permissions_screen |
| 群组分栏 | 自己起名;每社群至少一栏删不掉;删栏里的群改挂第一栏;公告也能归栏;≤ 30 栏、栏名 ≤ 60 字 | …/group-sections*、…/group-layout |
community_groups_screen |
| 社群里建群 / 挂现有群 | 按 who_can_create_groups;approval 档 202 进推荐队列;一群一社群;频道不能挂;工作房能挂 |
POST /v1/groups + POST …/groups、GET /v1/me/attachable-groups、…/group-suggestions* |
community_groups_screen、communities_screen |
| 群可见性 | community(社群成员可见)/ members(群内可见);只能收紧 |
PATCH …/groups/{convId} |
社群侧故意不画(§4) |
| 停用 / 恢复群 | 只对 General;停用后全群禁言 | POST …/groups/{convId}/deactivate |
community_groups_screen |
| 退社群 | 先退光非 General 群,否则 409 摊开清单 | POST …/leave |
community_info_screen |
| 社群行 | 和聊天混排;置顶 / 静音 / 归档;未读 = 该社群下所有会话未读求和 | PATCH /v1/communities/{id}、GET /v1/me/communities |
conversations_screen |
| 群设置 | 描述(≤ 500 字)/ 限时消息四档 / 谁能发 / 谁能改信息 / 谁能加人 / 批准新成员;改设置要 admin,只改描述看「谁能改信息」 | GET/PATCH …/settings |
group_info_screen(一页到底) |
| 群成员管理 | 加人(好友 / AI)、禁言、移出、任免管理员、转让;owner 必须先转让才能退 | …/members*、…/silence、…/transfer、…/leave |
group_info_screen、member_action_sheet |
| 入群申请 / 管理员申请 | 一屏两条队列;admin 批入群、owner 批管理员;409 = 已处理 | …/join-requests*、…/admin-requests* |
group_join_requests_screen |
| 群预览 | 没进的群点进去是预览不是聊天;五态 CTA | GET …/preview |
group_preview_screen |
| 成员流水 | add / join / remove / leave / role,只放群内成员看 | GET …/member-events |
— |
| 共同群 | 只算普通群、双方都在、排除我设了暗号的会话 | GET /v1/users/{id}/common-groups |
common_groups_section |
| 频道 | 建(起名必填);只读条;发现区(官方优先、按关注数取 50);关注 = 自助 join;关注数角标 | POST /v1/channels、GET /v1/channels/discover、POST …/join |
new_channel_screen、channel_* |
| 邀请链接 | 群 /g/{code}、社群 /cm/{code};重置作废旧的;无过期无次数 |
…/invite-link*、/v1/group-invites/{code}*、/v1/community-invites/{code}* |
invite_link_screen |
出处:社群路由
[code@9e9a7896 backend/internal/domain/community/community.go:70-120]、分栏[code@9e9a7896 backend/internal/domain/community/sections.go:47-58]、链接invite_link.go:242-245;群 / 频道路由[code@9e9a7896 backend/internal/domain/conversation/handler.go:188-239];手机各屏见 §1 出处与[code@9e9a7896 app/lib/screens/community_info_screen.dart:19,385,880-946,1080,1213]、[code@9e9a7896 app/lib/screens/group_info_screen.dart:75,471,486,799,965-1017,1113-1134,1216,1315,1347,1385]。
3. 内部怎么运作
3.1 社群是什么:三条判据、四张表、两间屋子
用户看到的:社群在会话列表里像一条聊天,点进去是主页(成员条 + 公告 + 大厅 + 分栏的群),不是聊天窗。
怎么做的:社群自己没有消息流;不进 conversations;公告复用 type='channel';成员表显式(不拿子群并集算)。四张表:communities、community_members(PK 双列,role owner / admin / member)、community_groups(PK 双列 + 唯一索引「一个群只能属于一个社群」,is_general / deactivated_at / visibility / section_id)、community_invites(Go 里零引用,废表)。视图 community_rooms = 公告 ∪ General,所有「进社群 = 进两间屋子」「角色跟进屋子」都靠它。群目录:visibility='members' 的群只有群内成员看得见;排序按分栏 position → sort_order → 最后活跃;公告频道不写进 community_groups,读时现拼一行(is_announcement,归 announcement_section_id)。社群头像换了,公告和 General 跟着换(000286)。
出处:三条判据
community.go:1-14;表 000214、is_general000243、偏好三列 000251、视图 000254、置顶 000258、存量回填 000253 / 000278、头像跟随 000286、announcement_section_id000299;群目录community.go:498-653(members 可见性:547-550,排序:569-572),公告现拼:665-721;废表零引用(全仓 grep);手机社群主页公告行[code@9e9a7896 app/lib/screens/communities_screen.dart:766];守卫announcement_in_list_test.go:22、announcement_section_test.go:24、general_pinned_test.go:23-68。
3.2 角色与权限:社群三层、群三层,谁能动谁
用户看到的:成员墙长按能设 / 撤管理员、转让、移出,但 owner 的行动不了;群里 owner 想退得先转让;转让后自己变成管理员(社群)或普通成员(群)。
怎么做的:社群 community_members.role 三层;任免 owner / admin 都能做,但 owner 的角色不可从任免接口改,任免同步到两间屋子;移出:owner 不可被移出,删成员表并只退公告 + 大厅两间屋子(leaveCommunityRooms),他在其他子群里的成员行原样留着,不发通知 —— 和本人 leave 的「先退光子群」是两条不同的路;转让只有 owner、对方须已是成员,事务里 owner → admin、对方 → owner,并同步屋子里的角色,写 community_role 铃铛。群 conversation_members.role 三层,isAdmin = owner || admin;禁言:admin 可禁言,不能禁言 owner / 自己,只有 owner 能禁言 admin;移出同规则;退群 owner 必须先转让;群转让老 owner 降 member(社群那条降 admin,两处不一样);两条退出路都过 afterLeft(→ 通话请出,见 calls)。频道退出不写系统消息。权限手机端不缓存:离线看得见名单,操作按钮一律等本次网络。
出处:社群任免
[code@9e9a7896 backend/internal/domain/community/roles.go:38-135,155-216],移出只退两间屋子[code@9e9a7896 backend/internal/domain/community/rooms.go:29-45,75-78],转让[code@9e9a7896 backend/internal/domain/community/transfer.go:45-149];群角色[code@9e9a7896 backend/internal/domain/conversation/repository.go:665,689,744,752-769],isAdminhandler.go:241,禁言handler.go:723-757+repository.go:565-583(000006),移出:693-720,退群:784-808,afterLeft:92-109;手机成员墙[code@9e9a7896 app/lib/widgets/community_members_sheet.dart:20,317,337,377],长按弹层只判可见性[code@9e9a7896 app/lib/widgets/member_action_sheet.dart:16-20],权限不缓存[code@9e9a7896 app/lib/data/local_store.dart:735-742];守卫roles_guard_test.go:22-43、transfer_guard_test.go:13、remove_member_guard_test.go:12[decision@2026-08-22,Jeff / Gene:任命管理员、不要禁言、转让所有权]。
3.3 群设置与发言闸:四样两样,三态锁,可见性只能收紧
用户看到的:群信息页底部一张单子:谁能发、谁能改信息、限时消息四档、批准新成员;被社群停用的群输入框换成一条「已停用」;仅管理员可发时普通人输入框是只读条。
怎么做的:conversations 表上 description(≤ 500 字)/ disappearing_seconds ∈ {0, 1 天, 7 天, 90 天} / who_can_send / who_can_edit_info / who_can_add_members(均 all | admins)/ approve_new_members;改这五项要 admin,只改描述看 canEditInfo。发言闸 SendLocked 三态:LockDeactivated(社群停用)优先于 LockAdminsOnly;它在 chat 的闸序里排在禁言之后、频道只读门之前(docs/chat.md §3.2)。可见性单向锁:community_groups.visibility 默认 community,members → community 一律 400 visibility_locked,反向允许;改 / 移出群要社群管理员或该群管理员,连人一起移出只放社群管理员,公告频道不可移出。这不是 WhatsApp 的「加群那一刻定死」,是我们自己的定论:只拦「会让已经说出口的话被更多人看见」那个方向。
出处:设置四样两样
[code@9e9a7896 backend/internal/domain/conversation/group_settings.go:26-47,204-208,252-270](列 000235 / 000236);三态锁:110-149;闸序在 chat[code@9e9a7896 backend/internal/domain/message/service.go:380-390];可见性单向锁[code@9e9a7896 backend/internal/domain/community/group_membership.go:23-32,73-83,127-146];停用[code@9e9a7896 backend/internal/domain/community/deactivate.go:28-67];手机开关group_info_screen.dart:1216,1315,1347,1385,停用条复用只读条[code@9e9a7896 app/lib/widgets/channel_read_only_bar.dart:13-16],who_can_add_members没进单子group_info_screen.dart:1383;守卫group_settings_admin_test.go:18[decision@2026-08-16,Jeff:「1-4 跟你建议做」→ 可见性单向锁][decision@2026-08-17,Jeff:停用群组「可以」]。
3.4 分栏:每个社群至少一栏,公告也能归栏
用户看到的:社群主页的群按「列表」分组,自己起名、拖动排序、收合;删一栏时里面的群不会消失;没有「其他」。
怎么做的:表 community_group_sections,上限 30 栏、栏名 60 字;删栏至少留一个(last_section),被删栏里的群和公告改挂第一栏;新挂进来的群取最后一栏;公告归属是存的不是算的(announcement_section_id);回填 000339(每社群至少一列)、000340(合并同名重复列)。为什么没有「其他」:Jeff 09-04 语音「没有 Others 的…每一个社群一定要有一个 List,它删不掉,它可以 rename」。
出处:
[code@9e9a7896 backend/internal/domain/community/sections.go:47-58,223-255],表 000295,回填 000339 / 000340;手机分栏 / 收合community_groups_screen.dart:73-125;守卫no_other_bucket_test.go:57-125、default_section_test.go:26[decision@2026-08-25,Jeff:群组列表 A、B 自己起名][decision@2026-09-04,Jeff:没有 Others]。
3.5 入群与审批:群有,社群没有
见 §1.2。要点:群 approve_new_members → 202 + join_requests,admin 批;管理员申请 202,owner 批;社群 join_policy != open → 403,没有队列;RequestJoin 不发 WS,申请角标不实时涨。
出处:见 §1.2;角标不实时(CHANGELOG v0.529 自述)。
3.6 频道:只读门在落库咽喉
用户看到的:频道里非管理员没有输入框,只有一条只读条;可以点表情反应,不能发贴纸;新用户注册自动在官方「HOOP」频道里;聊天页有「发现频道」区。
怎么做的:type='channel' 是第七种会话(000187 放宽 CHECK,加 official 列 + 唯一索引只许一个官方频道);只读门 channelGate 在 message 的落库前:类型 / 角色查询正常且 type=channel 且非 owner / admin → ErrChannelReadOnly → 403 channel_read_only;查询报错(terr != nil,如会话不存在)不拦、返回 nil,让后面的 INSERT 用外键错自然报 —— 所以它是「查得出来就拦」,不是「查不出来也拦」,外键也不是角色校验。它和同一条路上的 SendLocked 失败策略不同:SendLocked 查询报错会把错误往外传(docs/chat.md §3.2)。通话侧 gcall_http.go 是另一段代码:类型查询正常且是 channel 时查角色,角色查不出来当非管理员拒(失败方向和消息侧相反)。建频道 creator 为 owner;关注 = 插 member 并复活 left_at;发现列表排除我已在的,按 official DESC, followers DESC 取 50;官方频道:000187 建固定 id 的 HOOP 频道并拉进当时所有用户,新用户注册走 JoinOfficialChannel(幂等)。系统消息走 sys: token,客户端渲染、后端不拼中文;多语言公告 body_i18n(000194,精确语言 → 英文兜底)。手机端「取消关注」没有独立入口(走普通退群路径);人类 admin 在 App 里填多语言公告的输入 UI 没做。
出处:只读门
[code@9e9a7896 backend/internal/domain/message/repository.go:145-152](terr != nil不拦:150)、SendLocked传错[code@9e9a7896 backend/internal/domain/message/service.go:385-389]、错误码message/handler.go:1430-1431、通话侧[code@9e9a7896 backend/internal/domain/calls/gcall_http.go:185-191](角色查不出当非管理员拒);建频道 / 关注 / 发现[code@9e9a7896 backend/internal/domain/conversation/repository.go:263-296,308-332](取 50handler.go:567);官方自动进[code@9e9a7896 backend/internal/domain/auth/repository.go:161-168],000187;sys:说明conversation/handler.go:142-149;手机只读条channel_read_only_bar.dart:16,发现区[code@9e9a7896 app/lib/widgets/channel_discover_section.dart:22-38],关注复用 joinconversations_screen.dart:960-969,角标[code@9e9a7896 app/lib/widgets/channel_followers_badge.dart:18-31][decision@2026-08-01,Jeff:频道三条 —— owner / admin 才能发、只看 + emoji、只有官方频道新人自动进][decision@2026-08-05,Jeff:公告多语言英文兜底]。
3.7 手机端:缓存与刷新
怎么做的:群信息只缓存 title / 公告 / 成员名单,不存我的 role(会让按钮可见但点了 403);社群列表快照 cmty:list 聊天列表和社群主页读同一份,存未过滤的原样名单;社群详情 / 群列表 / 分栏按社群分快照;社群未读 = 该社群下所有会话未读求和,栏头合计含「全静音」判据。成员 / 设置变化没有独立 WS 类型:群信息页只认本会话的 message.new 且 body 以 sys: 开头(改名 / 进群 / 退群 / 公告 / 转让都走它)再轻刷;社群那几行不走 WS,靠轮询 + 重试。
出处:
[code@9e9a7896 app/lib/data/local_store.dart:735-760];快照communities_screen.dart:144,153、conversations_screen.dart:644-666、community_groups_screen.dart:73-90;未读合计conversations_screen.dart:466-551、communities_screen.dart:1240;WS 判据[code@9e9a7896 app/lib/screens/group_info_screen.dart:66,538-547],社群轮询conversations_screen.dart:612,653。
4. 还没做的(不是现状)
| 项 | 状态 | 出处 |
|---|---|---|
| 社群层加入审批队列;凭链接进非 open 社群 | 未做(P2),一律 403 needs_approval;「拿着链接算不算被邀请」待 Jeff 拍 |
community.go:834,874-882;CHANGELOG v0.973 |
| 手机端社群 403 的「需要审批」文案 | 没有,落进通用「加入失败」 | invite_link_screen.dart:26-29,190 |
| 邀请链接过期 / 次数上限 | 未做;000214 的 community_invites 有字段但 Go 零引用 |
000214、000239、000288 |
| 000288 的 SQL | LEDGER 明写从没在任何库跑过 → 生产状态待核 | LEDGER 000288 |
| 社群 / 频道搜索与发现 | 社群没有;频道只有「发现频道」区 | COMMUNITY.md §6 P2;CHANNEL_ROOMS 二期 |
| 谁能建社群 / 挂靠要不要群主同意 | 待 Jeff 拍(现状:所有人能建;挂靠按 who_can_create_groups) |
COMMUNITY.md §7 |
| 删社群 | 没有端点;COMMUNITY.md 记的「删社群困住子群」洞触发不了 | 全仓无 delete community 端点 |
| 可见性等四样搬到「新建群组」页 | 随「1-4」拍了,未见落地 | COMMUNITY_PERMISSIONS §4③ |
| 社群侧群组描述 / 限时 / 可见性 / 权限四样 UI;红减号移出群 | 手机端故意不画(注释说库里无字段 —— 可见性字段其实有,后端也有 detach 端点,注释陈了) | community_groups_screen.dart:10-17;community.go:78 |
群设置单子里的 who_can_add_members |
没摆进去,只读它决定入口画不画 | group_info_screen.dart:1383 |
| 入群申请角标实时 | RequestJoin 不发 WS |
CHANGELOG v0.529 |
| 「已批准但没进群 / 没升职」的补偿 | 批准和加成员 / 升职是两步,第二步失败没有回滚也没见重试;加成员自身又是「批 + 两句独立写」,失败可能停在任一层;有没有别处补偿未核 | join_gate.go:170-177,291-300、admin_request.go:75,166-175 |
| 人类 admin 填多语言公告的 UI | 没做,只有脚本走 API | CHANNEL_ROOMS |
| 频道取消关注独立入口 | 没有,走退群 | App 侧 grep |
group_settings_screen.dart |
lib 内无调用点,只剩 5 个测试引用 | group_info_screen.dart:31 |
| 社群转让老 owner 降 admin、群转让降 member | 两处不一样,是不是有意待拍 | transfer.go:93-95、repository.go:752-769 |
| 「发对话记录」是否真存在 | 未核 | COMMUNITY_PERMISSIONS §4② |
5. 出问题先查哪里 · 动它之前
排障一条路:
1. 人在群里却不在社群(或反过来):先区分两种预期例外和一种失败中间态 —— 预期例外:被管理员移出社群的人本来就还在子群里;经挂群吸收或经子群进来、以前退过大厅的人本来就不在大厅里。失败中间态:申请 approved 但后面执行失败的人在队列里是「已批准」,子群里可能有也可能没有,社群成员表和两间屋子可能只同步了一半(§1.2)。都不是,再看 community_members 有没有他、community_rooms 两间屋子的 conversation_members 有没有他(left_at);存量靠 000253 / 000278 回填过。
2. 加不进去:先看状态码 —— 202 = 群开了审批,join_requests 有他一行,找 admin 批;批了还没进 = 先看申请是不是已经 approved,再依次看目标子群的有效成员行(left_at IS NULL)、community_members、公告 / 大厅两间屋子的 left_at,缺哪一层补哪一层,核对后由有权限者尝试直接加人补办、其他恢复路径待核;403 needs_approval = 社群非 open(没有队列,只能改 policy 或让管理员直接加);403 无权 = 加人策略;400 visibility_locked = 想把 members 改回 community。
3. 发不出消息:按 chat 的闸序看是禁言、SendLocked(停用 / 仅管理员)还是 channelGate;频道里非管理员发进去了,先看那次类型 / 角色查询是不是报了错(只读门查询失败不拦)。
4. 社群主页空白:先分「请求失败」和「真没有」(v1.893 那次是半边失败被写进快照)。
5. 退不了社群:409 回的清单就是还在的非 General 群。
改它之前:
- 动社群写操作:community_members 和 community_rooms 两间屋子要一起动(守卫 roles_guard_test 逼);挂群 / 加人都要吸收。
- 加社群端点:两套待办队列(推荐 / 建群审批)刻意合表,别再各建一张(create_policy.go:10)。
- 动分栏:每次插群带 section;删栏保底改挂;layout 不写 NULL(no_other_bucket_test)。
- 动可见性:只允许收紧;改闸要同时改 group_membership.go 和守卫。
- 动频道只读:门在 message/repository.go 落库处,别搬到界面。
- 动入群闸:202 要单独一态,不能并进 200(invite_link_screen.dart:30-32);批量决定一条一条发、中途失败不吞。
- 别信的旧图纸:COMMUNITY.md 顶上「未开工」是错的、§4.1「删栏回其他」已被 000339 / 000340 推翻;COMMUNITY_PERMISSIONS 的四条待拍板 08-16 一次拍完、「我做错了」那句已过期;community_info_screen.dart:1000-1006 「社群没有邀请链接」注释陈了。
6. 不能碰的数(当前值 / 实现出处 / 守卫或未覆盖)
🔒 = Jeff / 评审明确锁定的;其余是普通配置值,改之前把守卫一起改。
| 数 | 当前值 | 实现出处(9e9a7896) | 守卫 / 未覆盖 |
|---|---|---|---|
| 一群一社群 🔒 | 唯一索引 | 000214 uq_community_groups_one_home |
DB 约束 |
| 每社群至少一栏 🔒 | 1(删不掉) | sections.go:223-227、000339 |
no_other_bucket_test.go:81-125 |
| 分栏上限 / 栏名 | 30 / 60 字 | sections.go:47,51 |
未覆盖 |
| 群描述 | ≤ 500 字 | group_settings.go:270 |
未覆盖 |
| 限时消息档位 | 0 / 1 天 / 7 天 / 90 天 | group_settings.go:26-31 |
未覆盖 |
| 可见性方向 🔒 | 只许 community → members | group_membership.go:73-83 |
未覆盖(行为由 400 错误码体现) |
| 发现频道取数 | 50 | conversation/handler.go:567 |
未覆盖 |
| 邀请码 | base62 / 22 位 / hoopcomm.com |
pkg/invitecode |
社群 invite_link_test.go:19,56,124、群 invite_link_test.go:12,43 |
| 邀请链接过期 / 次数 | 无 | 表结构 | — |
| 成员流水分页 | limit ≤ 100 | member_events.go:96-99 |
未覆盖 |
| 社群转让后老 owner | admin(群是 member) | transfer.go:93-95 / repository.go:752-769 |
transfer_guard_test.go:13(社群) |
7. 出处与旧图纸去向
- 代码(全部
[code@9e9a7896]):backend/internal/domain/community/{community,cards,create_policy,absorb,deactivate,group_membership,invite_link,links,media,prefs,roles,sections,suggestions,transfer,rooms}.go、backend/internal/domain/conversation/{handler,repository,group_settings,join_gate,join_tx,admin_request,member_events,group_preview,invite_link,common_groups}.go、backend/internal/domain/message/{repository,handler,service}.go(闸只引)、backend/internal/domain/auth/repository.go(官方频道)、backend/internal/domain/calls/gcall_http.go(频道判据);Applib/screens/{communities_screen,community_intro_screen,community_info_screen,community_groups_screen,community_group_policy_screen,community_permissions_screen,new_group_screen,new_channel_screen,group_info_screen,group_join_requests_screen,group_preview_screen,add_members_screen,invite_link_screen,conversations_screen}.dart、lib/widgets/{community_card_message,community_members_bar,community_members_open,community_members_sheet,channel_discover_section,channel_read_only_bar,channel_followers_badge,common_groups_section,member_action_sheet,member_status_avatar}.dart、lib/data/local_store.dart、lib/util/group_deactivated.dart。 - 迁移:000005 群公告、000006 禁言、000187 频道 + 官方、000194
body_i18n、000214 社群四表、000235 群设置四样 + 可见性、000236 加人 / 审批 /join_requests/ 推荐、000239 群邀请链接、000240 成员流水、000243is_general/deactivated_at、000251 社群偏好、000253 / 000278 回填、000254 视图community_rooms、000258 General 置顶、000284who_can_create_groups、000286 头像跟随、000288 社群邀请链接、000290who_can_add_members、000295 分栏、000299announcement_section_id、000308admin_requests、000339 / 000340(本模组最新;仓库最新 000360)。 - Redis:community 与 conversation 两个包没有任何 Redis 调用。
- 生产:本文没有在生产机核任何项(000288 是否真跑过、Caddy
/cm/*重写是否真部署了 —— 仓里 Caddyfile 有守卫invite_link_test.go:103,生产那份没核、官方频道 id)→[prod@ 待核]。 - 决定:07-29 Jeff 一房一主;08-01 Jeff 频道三条;08-05 Jeff 公告多语言英文兜底;08-12 Jeff 社群三条判据、「我们可不是为游戏而做的」;08-13 Jeff 拉人进社群补进 P1、社群行和聊天混排;08-16 Jeff「1-4 跟你建议做」(谁能拉人 / 新人审批 / 成员建议群 / 可见性单向锁);08-17 Jeff 社群也是一种群、停用群组;08-18 Jeff 进社群 = 进 General 和公告;08-19 Jeff General 天生置顶;08-21 Jeff 退群才能退社群、群对每个人一样;08-22 Jeff / Gene 任免管理员、不要禁言、转让;08-23 Jeff 头像连带;08-24 Jeff 社群二维码、社群权限两条;08-25 Jeff 群组分栏;08-26 Jeff 公告归栏;08-28 李敏 申请当管理员;09-03 Mike Ng 成员墙点头像进资料页(翻掉 08-20「好友直接进聊天」);09-04 Jeff 没有 Others。
- 未核清单:① 生产未核(见上);② 000288 的 SQL 是否真跑过(LEDGER 说没有);③ 「发对话记录」是否存在;④ 社群转让降 admin、群转让降 member 是否有意;⑤ 000214
community_invites废表是否该删(动库先问);⑥ 手机端注释「后端无 detach 接口」与community.go:78矛盾,以代码为准,App 那张红减号是否要做待拍;⑦ 入群 / 管理员申请「已批准但第二步失败」有没有补偿或重试(代码里没见,别处未核)。 - 旧图纸去向:
COMMUNITY.md整份被本文替代(判据、四场景、WhatsApp 研究结论并入;§4.1 分栏按 000299 / 000339 / 000340 重写)→_archive/(等 maintainer 挪);COMMUNITY_PERMISSIONS.md整份替代(WhatsApp 对照 + 「为什么我们不一样」并进 §3.3,四条待拍板已定);CHANNEL_ROOMS.md留给 rooms(频道房型本体),本文只吸收只读门与公告零件两条;CHANNEL_CONTENT.md归内容 / 增长线,不吃;WORK_ROOMS.md留给 rooms,本文只补「工作房可挂进社群」;群邀请链接 000239 与成员流水 000240 由本文写(上一份 contacts 指过来),docs/chat.md只引闸序。