conversation_ext

package
v1.8.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 4, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrVersionConflict 在 UpdateSort 的 CAS 失败时返回。
	// 调用方应用 errors.Is 判定并重试。
	ErrVersionConflict = errors.New("version conflict: please retry")
	// ErrSortTargetNotFound 在 UpdateSort 指定的 target 任一不存在时返回。
	// 任一 SELECT ... FOR UPDATE 落空都意味着 CAS 的"能锁住所有目标行"前提崩塌,
	// 不能静默成功,必须显式拒绝。
	ErrSortTargetNotFound = errors.New("sort target not found")
)

错误定义

View Source
var ErrChannelForbidden = errors.New("channel follow forbidden: not a member of the group or group not visible")

ErrChannelForbidden 在 FollowChannel 鉴权失败时返回。 调用方(HTTP handler)应将此错误翻译为 403。

引入背景(PR #123 round-1 review by Jerry-Xin / yujiawei):FollowChannel 不再只是 inert 的"清自己的黑名单",而是会触发 thread ext fanout 订阅 + 物化既有子区,因此必须在写前校验 caller 是该 group 的成员且该群在请求 Space 可见。

View Source
var ErrDMCategoryForbidden = errors.New("dm category forbidden: not owned by uid or category deleted")

ErrDMCategoryForbidden 在 FollowDM 指定的 category 不属于当前 uid 或已删除时返回。 调用方应将此错误翻译为 400 / 403(按业务约定)。 PR #21 Round-6 (Jerry-Xin):DM category 必须由服务端校验归属,否则客户端可写入 任意 UUID 让自己的 follow tab 引用不存在的分类("未分类"渲染)。

View Source
var ErrDefaultFollowedGuardNotConfigured = errors.New("default-followed group guard not configured")

ErrDefaultFollowedGuardNotConfigured 表示 AuthorizeAndMaterializeDefaultFollowed- Groups 被调用时 service 上还没有注入 DefaultFollowedGroupGuard。生产路径里 message/1module.go 启动时必定注入;返回这个 sentinel 让 unit test / 错误启动 顺序可观测,而不是悄无声息地把所有群当作未授权(issue #151 re-review M1)。

View Source
var ErrThreadForbidden = errors.New("thread follow forbidden: not a member of parent group or thread not visible")

ErrThreadForbidden 在 FollowThread 鉴权失败时返回。 调用方(HTTP handler)应将此错误翻译为 403。

Functions

func BumpFollowVersionTx

func BumpFollowVersionTx(tx *dbr.Tx, uid, spaceID string) (int64, error)

BumpFollowVersionTx 在 tx 内把 (uid, space_id) 的 version +1。 行不存在时以 version=1 创建。返回新 version。

任何 follow 状态变更(Follow/Unfollow/Sort/Category/Cleanup)都必须在 同一 tx 里调用本函数,这样客户端只要观察 follow_version 就能感知到 "自己的 follow 列表发生了变化"。

func ClearAutoFollowThreadsTx added in v1.4.1

func ClearAutoFollowThreadsTx(tx *dbr.Tx, uid, spaceID string, groupNos []string) error

ClearAutoFollowThreadsTx is the cleanup counterpart of MaterializeDefaultFollowedGroups. It sets auto_follow_threads=0 on the (uid, space_id, target_type=2, group_no) ext rows in groupNos that exist, silently skipping any that don't. Idempotent; safe to call when no row has been materialized.

Why it exists (issue #151 review #3 by yujiawei): MaterializeDefault- FollowedGroups creates ext rows with auto_follow_threads=1 for groups that are followed *implicitly* via group_setting.category_id. When that implicit follow is revoked (user moves the group out of any category, or the category is deleted), the row remains and selectEligibleForFanoutTx keeps treating the user as eligible for new-thread fan-out — even though buildFollowItems now drops the group from the follow tab because CategoryID is nil. Before this PR no row existed so the cleanup was implicit ("no row = no fanout"); once we materialize, the cleanup must be made explicit at every site that clears group_setting.category_id.

What it does NOT do:

  • Does not delete the ext row (preserves group_unfollowed flag and any follow_sort the user explicitly chose).
  • Does not touch thread (target_type=5) ext rows — existing thread subscriptions remain valid; we only stop auto-following NEW threads.
  • Does not bump user_follow_version — the caller (category handler) already bumps once for the surrounding group_setting change; bumping again here would force two client reloads for one logical action.

Callers in scope: modules/category moveGroupToCategory move-out branch (req.CategoryID == ""). modules/category deleteCategory does NOT need this helper — it already calls UnfollowGroupsTx which sets group_unfollowed=1 AND auto_follow_threads=0 (stronger semantic: explicit unfollow on category delete per PM contract). Future code that mutates group_setting.category_id from non-NULL to NULL without setting group_unfollowed=1 MUST call this helper in the same transaction to keep the read/write contracts in sync.

func InitGlobalConvExtDB

func InitGlobalConvExtDB(ctx *config.Context)

InitGlobalConvExtDB initialises the package-level *DB singleton. It is idempotent: repeated calls after the first are no-ops (sync.Once). Called from 1module.go init so the singleton is ready before any cascade- cleanup function is invoked by group / thread / user modules.

func InitGlobalConvExtService

func InitGlobalConvExtService(ctx *config.Context)

InitGlobalConvExtService initialises the package-level *Service singleton. It is idempotent: repeated calls after the first are no-ops (sync.Once). Called from the module factory below so the singleton is ready before any handler or cascade-cleanup hook uses it.

func RemoveConvExtForChannel

func RemoveConvExtForChannel(channelID string, channelType uint8)

RemoveConvExtForChannel removes all ext rows for a given channel across every user. Intended for use when a group is disbanded or a thread is deleted.

When channelType equals targetTypeGroup (2) the function also deletes all child thread rows (target_type=5) whose target_id begins with "{channelID}____", so that a single call cleans up both the group and every thread in it.

Atomicity (PR review Blocking #2): when the cascade applies (channelType=2) the channel-row DELETE and the thread-cascade DELETE are wrapped in a single transaction so a partial failure cannot leave orphaned thread rows. Errors are logged as warnings and never propagated.

func RemoveConvExtForUser

func RemoveConvExtForUser(uid, peerUID string)

RemoveConvExtForUser cleans up all DM ext rows (target_type=1) from uid toward peerUID across every space. Intended for use when two users delete each other as friends. Errors are logged as warnings and never propagated.

PR review (Round 3) Blocking #1/#2 — affected (uid, spaceID) pairs have their user_follow_version bumped in the same transaction so the next sidebar sync observes the removal.

func RemoveConvExtForUserInSpace

func RemoveConvExtForUserInSpace(uid, spaceID, channelID string, channelType uint8)

RemoveConvExtForUserInSpace cleans up a user's ext rows for a specific channel and all its child threads within a given space. Intended for use when a user leaves a group or is kicked: call once for the group channel (channelType=2), and optionally once per thread (channelType=5).

When channelType equals targetTypeGroup (2) the function also deletes, in the same space, every thread row whose target_id begins with "{channelID}____", mirroring the cascade logic in service.UnfollowChannel.

Atomicity (PR review Blocking #2): when the cascade applies (channelType=2) the channel-row DELETE and the thread-cascade DELETE are wrapped in a single transaction so a partial failure cannot leave orphaned thread rows. Errors are still logged as warnings and never propagated so the caller's main flow is not interrupted; on failure the transaction rolls back and a subsequent retry can re-run the same cleanup cleanly.

func RestoreAutoFollowThreadsTx added in v1.4.1

func RestoreAutoFollowThreadsTx(tx *dbr.Tx, uid, spaceID string, groupNos []string) error

RestoreAutoFollowThreadsTx is the move-back counterpart of ClearAutoFollowThreadsTx. It sets auto_follow_threads=1 on the (uid, space_id, target_type=2, group_no) ext rows in groupNos that exist, silently skipping any that don't. Idempotent; safe to call when no row has been materialized (sidebar.Sync's later MaterializeDefaultFollowedGroups call will create the row with auto_follow_threads=1 anyway).

Why it exists (issue #151 review #4 by an9xyz): the lifecycle is symmetric: materialize on first follow-tab read sets =1; move-out clears to 0; move-back-into-a-category must restore =1, otherwise the sidebar materialization branch sees the existing groupExts entry (with =0) and skips its INSERT IGNORE. The user re-categorizes the group, the group re-appears in the follow tab via buildFollowItems, but OnThreadCreated's fan-out filter (auto_follow_threads=1 AND group_unfollowed=0) still excludes them — phantom missing fan-out.

Like Clear, this helper does NOT touch group_unfollowed (the user's explicit unfollow choice is preserved across category churn) and does NOT bump user_follow_version (caller's surrounding category change already bumps once).

Callers in scope: modules/category moveGroupToCategory move-in branch (req.CategoryID != ""), including first-time categorize (no row yet, no-op here, sidebar materializes later) and A→B category move (row exists with =1 already, no-op effectively). The single common path simplifies reasoning and removes the temptation to branch on subcases.

func UnfollowDMsByCategoryTx added in v1.3.0

func UnfollowDMsByCategoryTx(tx *dbr.Tx, uid, spaceID, categoryID string) error

UnfollowDMsByCategoryTx 在传入 tx 内删除 (uid, spaceID) 下 dm_category_id=categoryID 的全部 DM ext 行。

由调用方负责事先 BumpFollowVersionTx(同 UnfollowGroupsTx 的锁序约束)。 categoryID 为空时 no-op。

func UnfollowGroupsTx added in v1.3.0

func UnfollowGroupsTx(tx *dbr.Tx, uid, spaceID string, groupNos []string) error

UnfollowGroupsTx 在传入 tx 内把一组 (uid, spaceID) 下的群批量标记为取消关注 (group_unfollowed=1),并级联删除每个群下的 thread ext 行。

语义与 service.UnfollowChannel 一致,区别仅在于:

  • 作用于多个群,而非单个;
  • 由调用方持有 tx,并由调用方负责 BumpFollowVersionTx(必须在本函数之前 bump, 与 UpdateSort 同序拿 (version → ext) 锁,避免死锁,详见 PR #21 Round-3 blocker #2)。

groupNos 为空时 no-op。

Types

type ActiveMemberFilter added in v1.6.1

type ActiveMemberFilter interface {
	// FilterActiveMemberUIDs 返回 uids 中当前是 groupNo 活跃成员的子集。
	// 实现不要求保序,也不要求去重输入。
	FilterActiveMemberUIDs(groupNo string, uids []string) ([]string, error)
}

ActiveMemberFilter 把一批 uid 过滤为「仍是 groupNo 活跃成员」(is_deleted=0 AND status=Normal,排除被拉黑成员)的子集。窄接口、依赖倒置,由 message/1module.go 注入实现(group.IService),避免 conversation_ext 直接 import group 形成循环依赖。

引入背景(issue #351 / PR #345 mandatory follow-up):AuthorizeChannelFollow 对 GROUP follow 有意保持 permissive ExistMember,但 FollowChannel 不是纯群操作—— 它写 auto_follow_threads=1 并物化既有子区 ext 行;OnThreadCreated fanout 也只 re-check ext flag(auto_follow_threads=1 AND group_unfollowed=0),不查群成员 状态。被拉黑的父群成员因此持续收到既有/新建子区的 ext 行与创建通知(元数据层 泄漏;内容读已被 ExistMemberActive 门禁拦住)。本接口用于在这两条子区物化写 路径上按 active membership 过滤:GROUP 行本身的语义不变。

type ChannelAuthChecker added in v1.4.0

type ChannelAuthChecker interface {
	AuthorizeChannelFollow(uid, spaceID, groupNo string) error
}

ChannelAuthChecker 判定 FollowChannel 是否被授权。窄接口,与 ThreadAuthChecker 同样采用依赖倒置(避免 conversation_ext 直接 import group),由 message/1module.go 注入实现:调用 group.IService.ExistMember + group.DB 可见性逻辑。

鉴权失败返回 ErrChannelForbidden;基础设施错误以 wrap 后形式上传。

type ConvExtFields

type ConvExtFields struct {
	FollowedDM *int8
	// DMCategoryID 是 group_category.category_id(VARCHAR(32) UUID)。
	DMCategoryID    *string
	ClearDMCategory bool
	GroupUnfollowed *int8
	FollowSort      *int
	// AutoFollowThreads 仅在 target_type=2 行有意义,nil 表示不更新该字段。
	AutoFollowThreads *int8
}

ConvExtFields 描述 Upsert 时可更新的字段集合。 nil 指针表示该字段不参与更新。 ClearDMCategory 为 true 时把 dm_category_id 更新为 NULL (与 DMCategoryID 同时指定时 ClearDMCategory 优先)。

type DB

type DB struct {
	log.Log
	// contains filtered or unexported fields
}

DB 提供对 user_conversation_ext 表的访问。

func NewDB

func NewDB(ctx *config.Context) *DB

NewDB 构造 DB。

func (*DB) Delete

func (d *DB) Delete(uid, spaceID string, targetType uint8, targetID string) error

Delete 删除指定行。行不存在时也不返回错误。

func (*DB) Get

func (d *DB) Get(uid, spaceID string, targetType uint8, targetID string) (*Model, error)

Get 返回单行。行不存在时返回 (nil, nil)。

func (*DB) ListFollowedDM

func (d *DB) ListFollowedDM(uid, spaceID string) ([]*Model, error)

ListFollowedDM 返回所有 followed_dm=1 的 DM 行(target_type=1), 按 (dm_category_id ASC, follow_sort ASC) 排序。 dm_category_id 为 NULL 的行排在最前(NULL first)。

func (*DB) ListGroupExts

func (d *DB) ListGroupExts(uid, spaceID string) ([]*Model, error)

ListGroupExts 返回 target_type=2(群)的全部 ext 行(无视 group_unfollowed 标志)。

Issue #41:sidebar follow tab 需要按 follow_sort 排序群条目,而 follow_sort 写在 user_conversation_ext 上。已关注的群在用户从未拖拽前不一定存在 ext 行,缺失时 上层视为 FollowSort=0;存在 group_unfollowed=1 的行由 ListUnfollowedGroups 单独 过滤,本方法不再次区分以避免漏读已设置过 follow_sort 的群。

func (*DB) ListThreadExts

func (d *DB) ListThreadExts(uid, spaceID string) ([]*Model, error)

ListThreadExts 返回 target_type=5(子区)的 ext 行, 按 (follow_sort ASC) 排序。 用于 follow tab 里子区独立条目的构造。

func (*DB) ListUnfollowedGroups

func (d *DB) ListUnfollowedGroups(uid, spaceID string) ([]*Model, error)

ListUnfollowedGroups 返回 group_unfollowed=1 的群行(target_type=2)。 用于关注 Tab 判断某个群是否已"取消关注"。

func (*DB) MaterializeDefaultFollowedGroups added in v1.4.1

func (d *DB) MaterializeDefaultFollowedGroups(uid, spaceID string, groupNos []string) error

MaterializeDefaultFollowedGroups writes a fresh ext row (auto_follow_threads=1, group_unfollowed=0) for each (uid, space_id, target_type=2, group_no) tuple that does not yet have one. It is the data-layer entry point used by:

  • Sidebar.Sync: after buildFollowItems decides which groups belong in the follow tab (gated by group_setting.category_id IS NOT NULL), passes the missing ones here so OnThreadCreated fans out new threads going forward.
  • Service.AuthorizeAndMaterializeDefaultFollowedGroups: pre-flight step before UpdateSort, after DefaultFollowedGroupGuard has filtered the client-supplied payload down to groups the caller genuinely has in a category.

SECURITY contract: this function does NOT authorize the (uid, group_no) pair. Callers MUST verify upstream that the user is allowed to follow each group (member + visible, AND the group has category_id set, per issue #151 code review #1). Without that gate a malicious client can piggy-back arbitrary group IDs and start receiving thread fan-outs for groups they are not in — leaking thread metadata.

Fail-open contract for sidebar callers: a failure here must not block the sidebar response; the caller logs and continues. Pre-flight callers (UpdateSort path) propagate the error to the client.

func (*DB) UpdateSort

func (d *DB) UpdateSort(uid, spaceID string, items []SortItem, expectedVersion int64) error

UpdateSort 通过 CAS 一次性更新 follow_sort。

PR review Round-3 Blocking #1/#2/#5 修正后的并发一致性:

  • BEGIN → 用 FOR UPDATE 锁 user_follow_version 的 (uid, space_id) 行取当前值。
  • cur != expectedVersion → ErrVersionConflict(回滚)。
  • 用 (target_type, target_id) 升序 FOR UPDATE 锁 user_conversation_ext 全部目标行。 返回行数 != len(items) → ErrSortTargetNotFound(回滚)。
  • 对每一行 UPDATE follow_sort。RowsAffected ∈ {0,1}:0=新值与旧值相同(无变化, 仍视为成功),1=正常更新;>1 不可达(WHERE 含逻辑主键),仅作防御性守卫。
  • 最后在同 tx 内把 user_follow_version +1。
  • items 为空时什么也不做,返回 nil。

旧实现的漏洞:

(1) 只锁 items[0] → items[1..] 缺失会被 0 行 UPDATE 静默吞掉。
(2) 不同首 item 的并发调用没有共享锁行,完全交错执行。
(3) 把 per-row version 当成用户级 CAS 的锚,但新关注的行 version=0,
    与既存行不一致,导致 UpdateSort 要么硬通过要么永远失败。

修复是引入 user_follow_version 表 (uid, space_id, version) 作为 CAS 的单一根, 任何 follow 状态变化都对它 +1,从根上解决问题。

func (*DB) Upsert

func (d *DB) Upsert(uid, spaceID string, targetType uint8, targetID string, fields ConvExtFields) error

Upsert 以 (uid, space_id, target_type, target_id) 为 UK 做 INSERT OR UPDATE。 fields 中(非 nil 的)字段在 INSERT 时作为初值写入, 命中重复键时同样用这些字段 UPDATE。 当所有字段都为 nil 且 ClearDMCategory=false 时仅执行 INSERT IGNORE (存在则不变,不存在则按默认值插入)。

type DefaultFollowedGroupGuard added in v1.4.1

type DefaultFollowedGroupGuard interface {
	// FilterDefaultFollowed 返回 candidateGroupNos 中通过完整校验链的子集。
	// spaceID 必填——校验链 step 2 / step 3 需要它来判定群的可见性。
	FilterDefaultFollowed(uid, spaceID string, candidateGroupNos []string) ([]string, error)
}

DefaultFollowedGroupGuard 过滤客户端 UpdateSort payload 中的 target_type=2 候选项,只保留对该 uid + 当前 spaceID 真正"默认关注"的群。

引入背景(issue #151 code review #1):UpdateSort 一度在事务里对任意 target_type=2 缺失项做 INSERT IGNORE 物化(auto_follow_threads=1)。但 payload 完全由客户端 提交,恶意/异常客户端可借此为任意 group_no 创建 ext 行;之后 OnThreadCreated 给该用户 fanout 子区 ext 行 → sidebar 透出本不可见群的子区元数据。

校验链(issue #151 code review #2,spaceID 校验补强):

  1. 成员资格:用户当前是该群成员(group.IService.ExistMember);
  2. Space 可见性:群在请求 spaceID 内可见(同 ChannelAuthChecker / FollowChannel 的 internal-same-space / external sourceSpaceID-match / legacy wildcard 规则);
  3. Disband 拒绝:群已解散直接拒绝;
  4. 默认关注语义:group_setting.category_id IS NOT NULL(当前 uid,与 spaceID 无关——group_setting 是 user-scoped、跨 Space 共享,所以 step 1/2/3 是 必要的 Space 过滤,缺一不可)。

与 ChannelAuthChecker 的语义差别:ChannelAuthChecker 检查"caller 能否主动关注 该群";DefaultFollowedGroupGuard 在前者基础上额外要求"该群已被加入用户某 category"。 这样恶意客户端拿一个用户当前不在的群(或在别的 Space 设过 category 的旧 group_setting 残留)来提交 sort,guard 会一并拒绝,sidebar fanout 路径不会被毒化。

实现位于 modules/message(已直接 import group + group_setting),启动时通过 SetDefaultFollowedGroupGuard 注入;nil 时 fail-closed(拒绝任何物化)。

type Follow

type Follow struct {
	log.Log
	// contains filtered or unexported fields
}

Follow holds the 7 Follow/Unfollow API handlers.

func NewFollow

func NewFollow(svc followService, db sortDB) *Follow

NewFollow creates a Follow API handler.

func (*Follow) FollowChannel

func (f *Follow) FollowChannel(c *wkhttp.Context)

FollowChannel 重新关注群(清黑名单) POST /v1/follow/channel/refollow

func (*Follow) FollowDM

func (f *Follow) FollowDM(c *wkhttp.Context)

FollowDM 关注 DM 并可选指定分组 POST /v1/follow/dm

func (*Follow) FollowThread

func (f *Follow) FollowThread(c *wkhttp.Context)

FollowThread 关注子区(隐式连带父群) POST /v1/follow/thread

func (*Follow) UnfollowChannel

func (f *Follow) UnfollowChannel(c *wkhttp.Context)

UnfollowChannel 群"取消关注"(写黑名单) POST /v1/follow/channel/unfollow

func (*Follow) UnfollowDM

func (f *Follow) UnfollowDM(c *wkhttp.Context)

UnfollowDM 取消关注 DM DELETE /v1/follow/dm?peer_uid=xxx

func (*Follow) UnfollowThread

func (f *Follow) UnfollowThread(c *wkhttp.Context)

UnfollowThread 取消关注子区 DELETE /v1/follow/thread?thread_channel_id=xxx

func (*Follow) UpdateSort

func (f *Follow) UpdateSort(c *wkhttp.Context)

UpdateSort 关注 Tab 内手动排序 CAS PUT /v1/follow/sort

type FollowVersionDB

type FollowVersionDB struct {
	log.Log
	// contains filtered or unexported fields
}

FollowVersionDB 提供对 user_follow_version 表的访问。

PR review (Round 3) Blocking #1/#2 — 这个用户级单调序列号取代了 原先的 per-row user_conversation_ext.version, 用来表达 follow 状态的全部变化(follow / unfollow / sort / category)。

func NewFollowVersionDB

func NewFollowVersionDB(ctx *config.Context) *FollowVersionDB

NewFollowVersionDB 构造 FollowVersionDB。

func (*FollowVersionDB) Get

func (d *FollowVersionDB) Get(uid, spaceID string) (int64, error)

Get 返回 (uid, space_id) 当前的 version。行不存在时返回 0。 只读路径,不取锁。

func (*FollowVersionDB) LockTx

func (d *FollowVersionDB) LockTx(tx *dbr.Tx, uid, spaceID string) (int64, error)

LockTx 在 tx 内对 (uid, space_id) 行执行 SELECT ... FOR UPDATE。 行不存在时先 INSERT IGNORE 初始化(version=0)再锁。 用于 UpdateSort 的 CAS 判定。

type Model

type Model struct {
	ID         int64  `db:"id"`
	UID        string `db:"uid"`
	SpaceID    string `db:"space_id"`
	TargetType uint8  `db:"target_type"`
	TargetID   string `db:"target_id"`
	FollowedDM int8   `db:"followed_dm"`
	// DMCategoryID 是 group_category.category_id(VARCHAR(32) UUID),DM 与群
	// 共用同一分类 namespace(PR #21 Round-6,原型 image-v1.png 印证)。
	// NULL 表示未分类。
	DMCategoryID    *string `db:"dm_category_id"`
	GroupUnfollowed int8    `db:"group_unfollowed"`
	FollowSort      int     `db:"follow_sort"`
	// AutoFollowThreads = 1 表示关注 target_type=2 群后,新建子区时自动给该 (uid, space_id) 物化 thread ext 行。
	// 仅在 target_type=2 行上有意义;其他 target_type 写 0 保持。
	AutoFollowThreads int8      `db:"auto_follow_threads"`
	CreatedAt         time.Time `db:"created_at"`
	UpdatedAt         time.Time `db:"updated_at"`
}

Model 对应 user_conversation_ext 表的一行。

type Service

type Service struct {
	log.Log
	// contains filtered or unexported fields
}

Service encapsulates composite operations on user_conversation_ext that require a single transaction boundary. It intentionally avoids importing modules/group, modules/user, or modules/thread to prevent circular dependencies.

threadAuth 是 FollowThread 的鉴权钩子,由外部模块(在 1module.go 里把 group/thread 组合起来的实现)在启动时通过 SetThreadAuthChecker 注入。 为 nil 时跳过鉴权(仅供测试 / 迁移期使用)。

(历史 DMCategoryChecker 注入点 issue #75 / PR #79 fix 之后已移除——FollowDM 鉴权改为事务内 SELECT ... FOR UPDATE,见 authorizeDMCategoryInTx——曾经的 `dmCatAuth`/`SetDMCategoryChecker` 接口与对应的 message 模块注入也一起清掉。)

func GetGlobalConvExtService

func GetGlobalConvExtService() *Service

GetGlobalConvExtService returns the singleton *Service, or nil if InitGlobalConvExtService has not been called yet. External modules (group, thread, …) that inject cascade-cleanup hooks should call this to reach the service without importing anything else.

func NewService

func NewService(ctx *config.Context) *Service

NewService creates a Service.

func (*Service) AuthorizeAndMaterializeDefaultFollowedGroups added in v1.4.1

func (s *Service) AuthorizeAndMaterializeDefaultFollowedGroups(uid, spaceID string, candidateGroupNos []string) error

AuthorizeAndMaterializeDefaultFollowedGroups is the pre-flight step the /v1/follow/sort handler calls before db.UpdateSort. It accepts client- supplied group_no's (target_type=2 items from the sort payload), filters them through DefaultFollowedGroupGuard to retain only the genuine default- followed ones, and materializes ext rows for the survivors via db.MaterializeDefaultFollowedGroups.

Rationale (issue #151 code review #1): putting the materialization inside db.UpdateSort means trusting the client payload, which lets an attacker piggy-back arbitrary group IDs and start receiving thread fan-outs. Moving the gate up to the service layer keeps DB free of group/group_setting imports while still authorizing every materialization.

fail-closed: if no guard is registered (test/migration mode), no materialization happens — db.UpdateSort will then return ErrSortTargetNotFound for the missing groups, which is the safer default than silently allowing arbitrary materialization.

func (*Service) FollowChannel

func (s *Service) FollowChannel(uid, spaceID, groupNo string) error

FollowChannel marks the group as followed (group_unfollowed=0, auto_follow_threads=1) and materializes thread ext rows for up to maxAutoFollowThreadsPerChannel currently-active threads under the channel.

两阶段提交(bug fix #2 race window):

  1. Phase 1 (tx) :bump follow_version + upsert 群行 (group_unfollowed=0, auto_follow_threads=1),commit。auto_follow=1 一旦可见,并发新建的子区在 thread.Service post-commit hook 中触发的 OnThreadCreated 会把本用户当作 fanout 目标 ——── 这条 invariant 是覆盖 race window 的核心。

  2. Phase 2 (无 tx):enumerate 当前 active 子区。在 Phase 1 commit 之后 做这一步,意味着任意在 Phase 1 commit 之前已存在的子区都会进入快照; 任意在快照之后才创建的子区则由 Phase 1 commit 后的 OnThreadCreated 兜底。 两条路径合起来无遗漏;INSERT IGNORE 让任何重叠(同子区被两条路径都写) 安全降为 no-op。

  3. Phase 3 (tx) :bump follow_version + bulk INSERT IGNORE thread ext 行。 失败时记日志但保留 Phase 1 的写入 —— 客户端会感知到 version bump 而触发 重拉;missing 子区在下次 FollowChannel 或新子区 fanout 时补齐。

旧实现的 bug:enumerate 在 tx 外、auto_follow=1 写入之前;在 enumerate 与 commit 之间创建的子区会被永久遗漏 —— OnThreadCreated 看不到 auto_follow=1, enumerate 的快照也没拿到该子区。

follow_version bump 次数随路径而定(lml2468 round-3 nit 后精确化):

  • 鉴权失败 (ErrChannelForbidden):0 次(直接返回,无 tx)
  • 未注入 ThreadEnumerator / 群下无 active 子区:1 次(仅 Phase 1)
  • 正常路径(含 Phase 3 re-check 跳过):2 次 —— Phase 3 锁序修复 (Jerry-Xin / yujiawei round-2 P1) 后 bump 先于 ext 行 SELECT FOR UPDATE, 所以即便 re-check 判定 ineligible 仍会 +1(无害,客户端多刷一次 sidebar)。

关键不变量是 bump 次数与子区数量 N 无关(不会随 N 线性增长),保持小常数。

当未注入 ThreadEnumerator 时(单测 / 迁移期)跳过 Phase 2/3,仅写群行。

func (*Service) FollowDM

func (s *Service) FollowDM(uid, spaceID, peerUID string, categoryID *string) error

FollowDM marks the DM conversation with peerUID as followed (followed_dm=1). If categoryID is non-nil the DM is placed into that group_category UUID.

PR #21 Round-6 (Jerry-Xin):categoryID 类型由 *int64 改为 *string,与 group_category.category_id (VARCHAR(32) UUID) 一致;DM 与群共用同一分类 namespace 由原型 image-v1.png 证实。 校验顺序:

  • 入参合法(uid/spaceID/peerUID 非空)
  • 事务内 authorizeDMCategoryInTx 校验 categoryID 属于 uid 且 status==1 (issue #75:原 DMCategoryChecker 在事务外校验,存在 TOCTOU 窗口; 现在挪进 withTx 并配 SELECT ... FOR UPDATE)

PR review (Round 3) Blocking #1/#2 — bumps follow_version in same tx.

func (*Service) FollowThread

func (s *Service) FollowThread(uid, spaceID, threadChannelID string) error

FollowThread creates (or ensures) an ext row for the given thread channel, and simultaneously clears the parent group's unfollowed flag so that following a specific thread implicitly re-follows its parent group.

threadChannelID must have the format "{groupNo}____{shortID}".

PR review (Round 3) Blocking #3: prior to any DB write, the registered ThreadAuthChecker (if any) MUST authorise (uid, groupNo, shortID). Without this check FollowThread accepted any syntactically valid channel ID and wrote an ext row referencing a thread the user could not see — surfacing unauthorised entries on subsequent sidebar queries. ErrThreadForbidden bubbles up unchanged for the handler to translate to a 403 response.

func (*Service) OnThreadCreated added in v1.4.0

func (s *Service) OnThreadCreated(groupNo, shortID string) error

OnThreadCreated 在 thread.Service.CreateThread 提交 tx 之后调用,给所有 已对 parent channel 开启 auto_follow_threads=1 的用户物化 thread ext 行 并 bump 各自的 follow_version,从而实现"关注 channel 后新建子区自动跟随"。

设计说明(plan Q1 = C / fanout = 同步):

  • 同步执行(非异步队列)—— 客户端 sidebar 在 thread 创建消息送达后立刻能拉到新行。
  • 单独 tx —— thread.Service.CreateThread 自己的 tx 已 commit,与 IM 频道 / 子区 创建消息一样采取 best-effort post-commit hook 风格;fanout 失败只记日志不阻断 thread 创建本身。
  • INSERT IGNORE —— 用户既有的 thread 行(含已手动调过 follow_sort 的)保持不变。
  • follow_version bump —— 只对真正参与 fanout 的用户 +1。无 auto_follow 用户时整体 no-op。
  • 锁顺序与 FollowChannel 一致:每个用户先 BumpFollowVersionTx(在 version 行加 X 锁) 再写 ext 行,避免与 UpdateSort 反向死锁。

调用方应在 thread.Service.CreateThread 的 commit 之后立即调用,错误以 wrap 形式上传, 由调用方决定是否记日志 / 触发告警(thread 创建不应因 fanout 失败回滚)。

func (*Service) SetActiveMemberFilter added in v1.6.1

func (s *Service) SetActiveMemberFilter(f ActiveMemberFilter)

SetActiveMemberFilter injects the active-membership filter used by the two thread-ext materialization write paths (FollowChannel Phase 2/3 and the OnThreadCreated fanout) — issue #351. Safe for concurrent use; intended to be called once at startup from message/1module.go after the group module has initialised.

func (*Service) SetChannelAuthChecker added in v1.4.0

func (s *Service) SetChannelAuthChecker(c ChannelAuthChecker)

SetChannelAuthChecker injects the authorizer used by FollowChannel. Safe for concurrent use; intended to be called once at startup from message/1module.go after the group module has initialised.

func (*Service) SetDefaultFollowedGroupGuard added in v1.4.1

func (s *Service) SetDefaultFollowedGroupGuard(g DefaultFollowedGroupGuard)

SetDefaultFollowedGroupGuard injects the gate used by AuthorizeAndMaterializeDefaultFollowedGroups to filter UpdateSort payloads down to genuinely default-followed groups (issue #151 code review #1). Safe for concurrent use; intended to be called once at startup.

func (*Service) SetThreadAuthChecker

func (s *Service) SetThreadAuthChecker(c ThreadAuthChecker)

SetThreadAuthChecker injects the auth checker used by FollowThread. Safe for concurrent use; intended to be called once at startup from 1module.go after the group / thread modules have initialised.

func (*Service) SetThreadEnumerator added in v1.4.0

func (s *Service) SetThreadEnumerator(e ThreadEnumerator)

SetThreadEnumerator injects the enumerator used by FollowChannel to materialize thread ext rows for every active thread under the channel. Safe for concurrent use; intended to be called once at startup from message/1module.go after the thread module has initialised.

func (*Service) UnfollowChannel

func (s *Service) UnfollowChannel(uid, spaceID, groupNo string) error

UnfollowChannel marks the group as unfollowed (group_unfollowed=1) and, in the same transaction, deletes all thread (target_type=5) ext rows whose target_id starts with "{groupNo}____" for this user+space, and bumps the user_follow_version (PR review Round-3 Blocking #1/#2).

func (*Service) UnfollowDM

func (s *Service) UnfollowDM(uid, spaceID, peerUID string) error

UnfollowDM removes the ext row for the DM conversation with peerUID. Deleting is cleaner than setting followed_dm=0 because it frees the row and avoids stale dm_category_id values. PR review (Round 3) Blocking #1/#2 — bumps follow_version in same tx.

func (*Service) UnfollowThread

func (s *Service) UnfollowThread(uid, spaceID, threadChannelID string) error

UnfollowThread removes the ext row for the given thread channel. It does NOT touch the parent group's unfollowed flag.

threadChannelID must have the format "{groupNo}____{shortID}". PR review (Round 3) Blocking #1/#2 — bumps follow_version in same tx.

type SortItem

type SortItem struct {
	TargetType uint8
	TargetID   string
}

SortItem 是传给 UpdateSort 的单条排序项。

type ThreadAuthChecker

type ThreadAuthChecker interface {
	AuthorizeThreadFollow(uid, spaceID, groupNo, shortID string) error
}

ThreadAuthChecker 判定 FollowThread 是否被授权,是一个 narrow interface。 为避免对 modules/group / modules/thread 形成循环依赖,采用依赖倒置从外部注入。

AuthorizeThreadFollow 一次性校验:

  • shortID 对应的 thread 存在,且 status != deleted。
  • thread.group_no == 入参 groupNo(拒绝跨群引用)。
  • uid 是 groupNo 的成员。

鉴权失败返回 ErrThreadForbidden(具体原因由 handler 写日志)。 校验通过返回 nil。基础设施错误(DB 错误等)以 wrap 后的形式向上透传。

type ThreadEnumerator added in v1.4.0

type ThreadEnumerator interface {
	EnumerateActiveShortIDs(groupNo string, limit int) ([]string, error)
}

ThreadEnumerator 是 FollowChannel 级联物化子区时使用的窄接口。 与 ThreadAuthChecker 一样采用依赖倒置,避免 conversation_ext 直接 import thread。 由 message/1module.go 启动时通过 SetThreadEnumerator 注入;nil 时跳过物化 (供单测以及尚未注入的迁移期使用)。

实现 MUST 按 created_at DESC 排序返回(yujiawei round-2 nit)—— maxAutoFollowThreadsPerChannel 的截断语义依赖这条不变量:cap 命中时丢弃的是 最旧的子区,与产品侧"子区自动归档先归档旧子区"配合让"热"子区始终进入物化。 如果未来引入新的 enumerator 实现,必须保留这个顺序约定。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL