Skip to content

Commit 6e3264c

Browse files
authored
fix(follow): materialize ext rows for default-followed groups (#151)
Closes #151. Materializes user_conversation_ext rows for default-followed groups (those in a category but never explicitly touched) so /v1/follow/sort drag works and OnThreadCreated fans out new threads. Materialization is gated by a two-stage guard (group_setting × group_category live join + per-group membership/Space/Disband check via threadAuthChecker), and the move-out path cleans up auto_follow_threads in lock-step with the read predicate to keep buildFollowItems and selectEligibleForFanoutTx consistent. Schema-app invariant tightened: QueryCategorySettingsByGroupNos now selects gc.category_id so a soft-deleted category yields CategoryID=nil and is correctly excluded from the follow tab + materialization.
1 parent 8002db1 commit 6e3264c

16 files changed

Lines changed: 2244 additions & 9 deletions

modules/category/api.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,12 +638,49 @@ func (c *Category) moveGroupToCategory(ctx *wkhttp.Context) {
638638
}
639639
}
640640

641+
// Project-wide lock order is group_setting → user_follow_version →
642+
// user_conversation_ext (matches UpdateSort, FollowChannel, UnfollowGroupsTx,
643+
// deleteCategory). Bump version BEFORE writing ext: otherwise a concurrent
644+
// /v1/follow/sort holding the version FOR UPDATE while waiting on ext
645+
// would AB-BA-deadlock against a move-out holding ext while waiting on
646+
// version (issue #151 review #4 by an9xyz).
641647
if _, err := convext.BumpFollowVersionTx(tx, loginUID, groupSpaceID); err != nil {
642648
c.Error("更新 follow_version 失败", zap.Error(err))
643649
ctx.ResponseError(errors.New("更新群设置失败"))
644650
return
645651
}
646652

653+
// Issue #151 review #3/#4 — synchronize user_conversation_ext.auto_follow_threads
654+
// with the new category membership state. buildFollowItems decides follow-tab
655+
// membership by `cs.CategoryID != nil`; selectEligibleForFanoutTx decides
656+
// OnThreadCreated fan-out by `auto_follow_threads=1 AND group_unfollowed=0`.
657+
// These two reads share no column, so the writer (this handler) must keep
658+
// them in lockstep:
659+
//
660+
// - Move-out (categoryIDPtr == nil): clear auto_follow_threads to match
661+
// the new "not in follow tab" state. Without this, fan-out continues
662+
// to target a user whose follow tab no longer shows the group.
663+
// - Move-in (categoryIDPtr != nil): restore auto_follow_threads=1 if
664+
// an ext row already exists with =0 (left over from a prior move-out).
665+
// Sidebar materialization (api_sidebar.go) skips its INSERT IGNORE
666+
// when the ext row is already present, so without this restore the
667+
// group re-appears in the follow tab but fan-out stays disabled.
668+
// For first-time categorize (no ext row) the call is a no-op —
669+
// sidebar materialization later creates the row with =1.
670+
if categoryIDPtr == nil {
671+
if err := convext.ClearAutoFollowThreadsTx(tx, loginUID, groupSpaceID, []string{groupNo}); err != nil {
672+
c.Error("清理 auto_follow_threads 失败", zap.Error(err))
673+
ctx.ResponseError(errors.New("更新群设置失败"))
674+
return
675+
}
676+
} else {
677+
if err := convext.RestoreAutoFollowThreadsTx(tx, loginUID, groupSpaceID, []string{groupNo}); err != nil {
678+
c.Error("恢复 auto_follow_threads 失败", zap.Error(err))
679+
ctx.ResponseError(errors.New("更新群设置失败"))
680+
return
681+
}
682+
}
683+
647684
if err := tx.Commit(); err != nil {
648685
c.Error("提交事务失败", zap.Error(err))
649686
ctx.ResponseError(errors.New("更新群设置失败"))

modules/category/api_test.go

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,38 @@ import (
99
"sync"
1010
"testing"
1111

12+
"github.com/Mininglamp-OSS/octo-lib/config"
1213
"github.com/Mininglamp-OSS/octo-lib/pkg/util"
1314
"github.com/Mininglamp-OSS/octo-lib/pkg/wkhttp"
1415
"github.com/Mininglamp-OSS/octo-lib/testutil"
1516
convext "github.com/Mininglamp-OSS/octo-server/modules/conversation_ext"
17+
redis "github.com/go-redis/redis"
1618
"github.com/stretchr/testify/assert"
19+
"github.com/stretchr/testify/require"
1720
)
1821

22+
// resetUIDRateLimit clears the per-uid token-bucket keys
23+
// (ratelimit:uid:{uid}) so subsequent HTTP calls in this test start from a
24+
// full bucket. Without this, tests that came earlier in the same go test
25+
// binary will have consumed tokens, and a later high-burst test (e.g.
26+
// TestCategory_CreateLimit, which makes 20+ category POSTs back-to-back)
27+
// fails with HTTP 429 even though the per-test logic is correct. See
28+
// pkg/wkhttp/ratelimit_helper.go SharedUIDRateLimiter for the bucket key
29+
// scheme. Pattern mirrors modules/space/api_email_invite_public_test.go's
30+
// resetSpaceInviteRateLimit.
31+
func resetUIDRateLimit(t *testing.T, ctx *config.Context) {
32+
t.Helper()
33+
rdsClient := redis.NewClient(&redis.Options{
34+
Addr: ctx.GetConfig().DB.RedisAddr,
35+
Password: ctx.GetConfig().DB.RedisPass,
36+
})
37+
defer rdsClient.Close()
38+
keys, err := rdsClient.Keys("ratelimit:uid:*").Result()
39+
if err == nil && len(keys) > 0 {
40+
_ = rdsClient.Del(keys...).Err()
41+
}
42+
}
43+
1944
// ---------- helpers ----------
2045

2146
func resetDefaultCategoryName() {
@@ -427,6 +452,263 @@ func TestCategory_MoveGroupToCategory(t *testing.T) {
427452
assert.Nil(t, setting2.CategoryID)
428453
}
429454

455+
// TestCategory_MoveGroupOutOfCategory_ClearsAutoFollowThreads is the
456+
// regression test for issue #151 review #3 (yujiawei). When a user moves a
457+
// group out of any category, the auto_follow_threads flag on the
458+
// user_conversation_ext row (which the new sidebar materialization may have
459+
// set to 1) must be cleared in the same transaction. Without this cleanup,
460+
// selectEligibleForFanoutTx would keep this user eligible for OnThreadCreated
461+
// fan-out — the read side (buildFollowItems) drops the group because
462+
// CategoryID is now nil, but the write side only checks auto_follow_threads.
463+
//
464+
// Repro before fix:
465+
// 1. group g placed in category c, no ext row yet (default-followed).
466+
// 2. /v1/sidebar/sync follow tab materializes (uid, space, g) with
467+
// auto_follow_threads=1, group_unfollowed=0.
468+
// 3. User moves g out of category via PUT /v1/groups/g/category {"":""}.
469+
// 4. ext row still has auto_follow_threads=1 — fan-out continues.
470+
//
471+
// After fix the move-out branch in api.go calls ClearAutoFollowThreadsTx
472+
// in the same tx, restoring the read/write contract.
473+
func TestCategory_MoveGroupOutOfCategory_ClearsAutoFollowThreads(t *testing.T) {
474+
s, ctx := testutil.NewTestServer()
475+
f := New(ctx)
476+
477+
err := testutil.CleanAllTables(ctx)
478+
assert.NoError(t, err)
479+
resetUIDRateLimit(t, ctx)
480+
481+
spaceID := "space-move-clr-001"
482+
seedSpaceAndMember(t, f, spaceID, 0)
483+
route := s.GetRoute()
484+
485+
// 1. Set up: category + group + group-in-category.
486+
wc := createCategory(t, route, spaceID, "工作")
487+
require.Equal(t, http.StatusOK, wc.Code)
488+
cat := parseJSON(t, wc)
489+
catID := cat["category_id"].(string)
490+
491+
groupNo := "group-move-clr-001"
492+
seedGroup(t, f, groupNo, spaceID)
493+
494+
wm := doRequest(t, route, "PUT", "/v1/groups/"+groupNo+"/category", map[string]string{
495+
"category_id": catID,
496+
})
497+
require.Equal(t, http.StatusOK, wm.Code)
498+
499+
// 2. Simulate the sidebar materialization step (would normally fire on
500+
// /v1/sidebar/sync). Insert ext row with auto_follow_threads=1,
501+
// matching what MaterializeDefaultFollowedGroups writes.
502+
_, err = f.db.session.InsertBySql(
503+
"INSERT INTO user_conversation_ext (uid, space_id, target_type, target_id, group_unfollowed, auto_follow_threads) "+
504+
"VALUES (?, ?, 2, ?, 0, 1)",
505+
testutil.UID, spaceID, groupNo,
506+
).Exec()
507+
require.NoError(t, err, "seed materialized ext row")
508+
509+
// Precondition sanity check.
510+
var preAutoFollow int
511+
_, err = f.db.session.SelectBySql(
512+
"SELECT auto_follow_threads FROM user_conversation_ext"+
513+
" WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?",
514+
testutil.UID, spaceID, groupNo,
515+
).Load(&preAutoFollow)
516+
require.NoError(t, err)
517+
require.Equal(t, 1, preAutoFollow, "precondition: row is materialized auto_follow_threads=1")
518+
519+
// 3. Move group OUT of category.
520+
wm2 := doRequest(t, route, "PUT", "/v1/groups/"+groupNo+"/category", map[string]string{
521+
"category_id": "",
522+
})
523+
require.Equal(t, http.StatusOK, wm2.Code)
524+
525+
// 4. Assert auto_follow_threads is now 0 — the actual regression fix.
526+
var postAutoFollow int
527+
_, err = f.db.session.SelectBySql(
528+
"SELECT auto_follow_threads FROM user_conversation_ext"+
529+
" WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?",
530+
testutil.UID, spaceID, groupNo,
531+
).Load(&postAutoFollow)
532+
require.NoError(t, err)
533+
assert.Equal(t, 0, postAutoFollow,
534+
"auto_follow_threads must be cleared after move-out (issue #151 review #3); "+
535+
"otherwise selectEligibleForFanoutTx would still target this user")
536+
537+
// 5. Other flags MUST be preserved — uncategorize is NOT a full unfollow.
538+
var groupUnfollowed int
539+
_, err = f.db.session.SelectBySql(
540+
"SELECT group_unfollowed FROM user_conversation_ext"+
541+
" WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?",
542+
testutil.UID, spaceID, groupNo,
543+
).Load(&groupUnfollowed)
544+
require.NoError(t, err)
545+
assert.Equal(t, 0, groupUnfollowed,
546+
"group_unfollowed must NOT be set — uncategorize ≠ explicit unfollow; "+
547+
"the cleanup only revokes auto-subscribe to NEW threads, not all subscriptions")
548+
}
549+
550+
// TestCategory_MoveGroupBetweenCategories_PreservesAutoFollowThreads pins the
551+
// non-regression: moving a group from category A to category B preserves the
552+
// implicit follow, so auto_follow_threads stays 1. Without this guard, a
553+
// future change might over-eagerly clear in every move and break the
554+
// "default-followed across category-to-category move" contract.
555+
func TestCategory_MoveGroupBetweenCategories_PreservesAutoFollowThreads(t *testing.T) {
556+
s, ctx := testutil.NewTestServer()
557+
f := New(ctx)
558+
559+
err := testutil.CleanAllTables(ctx)
560+
assert.NoError(t, err)
561+
resetUIDRateLimit(t, ctx)
562+
563+
spaceID := "space-move-keep-001"
564+
seedSpaceAndMember(t, f, spaceID, 0)
565+
route := s.GetRoute()
566+
567+
// Two categories, one group, materialized ext row.
568+
wcA := createCategory(t, route, spaceID, "工作A")
569+
require.Equal(t, http.StatusOK, wcA.Code)
570+
catA := parseJSON(t, wcA)["category_id"].(string)
571+
wcB := createCategory(t, route, spaceID, "工作B")
572+
require.Equal(t, http.StatusOK, wcB.Code)
573+
catB := parseJSON(t, wcB)["category_id"].(string)
574+
575+
groupNo := "group-move-keep-001"
576+
seedGroup(t, f, groupNo, spaceID)
577+
require.Equal(t, http.StatusOK, doRequest(t, route, "PUT", "/v1/groups/"+groupNo+"/category", map[string]string{"category_id": catA}).Code)
578+
_, err = f.db.session.InsertBySql(
579+
"INSERT INTO user_conversation_ext (uid, space_id, target_type, target_id, group_unfollowed, auto_follow_threads) "+
580+
"VALUES (?, ?, 2, ?, 0, 1)",
581+
testutil.UID, spaceID, groupNo,
582+
).Exec()
583+
require.NoError(t, err)
584+
585+
// Move A → B.
586+
w := doRequest(t, route, "PUT", "/v1/groups/"+groupNo+"/category", map[string]string{"category_id": catB})
587+
require.Equal(t, http.StatusOK, w.Code)
588+
589+
var autoFollow int
590+
_, err = f.db.session.SelectBySql(
591+
"SELECT auto_follow_threads FROM user_conversation_ext"+
592+
" WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?",
593+
testutil.UID, spaceID, groupNo,
594+
).Load(&autoFollow)
595+
require.NoError(t, err)
596+
assert.Equal(t, 1, autoFollow,
597+
"category A→B move must NOT clear auto_follow_threads — the group is "+
598+
"still in the follow tab, just under a different category")
599+
}
600+
601+
// TestCategory_MoveGroupBackIntoCategory_RestoresAutoFollowThreads pins
602+
// issue #151 review #4 (an9xyz) symptom #1: a default-followed group that
603+
// has been materialized and then moved out must have auto_follow_threads
604+
// restored to 1 when it is moved back into any category. Otherwise the
605+
// sidebar materialization branch skips the existing groupExts entry,
606+
// the group reappears in the follow tab via buildFollowItems, but
607+
// selectEligibleForFanoutTx still excludes the user (=0) — phantom missing
608+
// fan-out.
609+
func TestCategory_MoveGroupBackIntoCategory_RestoresAutoFollowThreads(t *testing.T) {
610+
s, ctx := testutil.NewTestServer()
611+
f := New(ctx)
612+
613+
err := testutil.CleanAllTables(ctx)
614+
assert.NoError(t, err)
615+
resetUIDRateLimit(t, ctx)
616+
617+
spaceID := "space-move-back-001"
618+
seedSpaceAndMember(t, f, spaceID, 0)
619+
route := s.GetRoute()
620+
621+
wc := createCategory(t, route, spaceID, "工作")
622+
require.Equal(t, http.StatusOK, wc.Code)
623+
catID := parseJSON(t, wc)["category_id"].(string)
624+
625+
groupNo := "group-move-back-001"
626+
seedGroup(t, f, groupNo, spaceID)
627+
628+
// Cycle: move into category → simulate sidebar materialization → move
629+
// out (clears auto_follow_threads) → move back into the SAME category.
630+
require.Equal(t, http.StatusOK, doRequest(t, route, "PUT", "/v1/groups/"+groupNo+"/category", map[string]string{"category_id": catID}).Code)
631+
_, err = f.db.session.InsertBySql(
632+
"INSERT INTO user_conversation_ext (uid, space_id, target_type, target_id, group_unfollowed, auto_follow_threads) "+
633+
"VALUES (?, ?, 2, ?, 0, 1)",
634+
testutil.UID, spaceID, groupNo,
635+
).Exec()
636+
require.NoError(t, err, "simulate sidebar materialization")
637+
require.Equal(t, http.StatusOK, doRequest(t, route, "PUT", "/v1/groups/"+groupNo+"/category", map[string]string{"category_id": ""}).Code,
638+
"move out — must clear auto_follow_threads")
639+
640+
var afterOut int
641+
_, err = f.db.session.SelectBySql(
642+
"SELECT auto_follow_threads FROM user_conversation_ext"+
643+
" WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?",
644+
testutil.UID, spaceID, groupNo,
645+
).Load(&afterOut)
646+
require.NoError(t, err)
647+
require.Equal(t, 0, afterOut, "precondition: move-out cleared auto_follow_threads")
648+
649+
// Move BACK into the same category. Sidebar materialization would skip
650+
// this row (groupExts hit), so the move-in path itself must restore =1.
651+
require.Equal(t, http.StatusOK, doRequest(t, route, "PUT", "/v1/groups/"+groupNo+"/category", map[string]string{"category_id": catID}).Code)
652+
653+
var afterIn int
654+
_, err = f.db.session.SelectBySql(
655+
"SELECT auto_follow_threads FROM user_conversation_ext"+
656+
" WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?",
657+
testutil.UID, spaceID, groupNo,
658+
).Load(&afterIn)
659+
require.NoError(t, err)
660+
assert.Equal(t, 1, afterIn,
661+
"move-in must restore auto_follow_threads=1 on an existing ext row "+
662+
"(issue #151 review #4 symptom #1); sidebar materialization would "+
663+
"otherwise skip the existing row, leaving OnThreadCreated fan-out "+
664+
"disabled even though the group is back in the follow tab")
665+
}
666+
667+
// TestCategory_MoveFirstTimeIntoCategory_NoOpRestore ensures the move-in
668+
// restore call is a safe no-op when no ext row has been materialized yet —
669+
// sidebar materialization at the next /v1/sidebar/sync creates the row with
670+
// auto_follow_threads=1 anyway, and the move-in handler must not
671+
// short-circuit any subsequent paths or write inappropriate rows.
672+
func TestCategory_MoveFirstTimeIntoCategory_NoOpRestore(t *testing.T) {
673+
s, ctx := testutil.NewTestServer()
674+
f := New(ctx)
675+
676+
err := testutil.CleanAllTables(ctx)
677+
assert.NoError(t, err)
678+
resetUIDRateLimit(t, ctx)
679+
680+
spaceID := "space-move-first-001"
681+
seedSpaceAndMember(t, f, spaceID, 0)
682+
route := s.GetRoute()
683+
684+
wc := createCategory(t, route, spaceID, "工作")
685+
require.Equal(t, http.StatusOK, wc.Code)
686+
catID := parseJSON(t, wc)["category_id"].(string)
687+
688+
groupNo := "group-move-first-001"
689+
seedGroup(t, f, groupNo, spaceID)
690+
691+
// Move into category for the first time — no ext row exists yet.
692+
require.Equal(t, http.StatusOK, doRequest(t, route, "PUT", "/v1/groups/"+groupNo+"/category", map[string]string{"category_id": catID}).Code)
693+
694+
// No row should have been written by the move-in path — sidebar's
695+
// MaterializeDefaultFollowedGroups is the canonical materialization site
696+
// and stays solely responsible for creating ext rows. Letting the
697+
// move-in path INSERT here would race with the unique key and silently
698+
// pick whichever flag set ends up committing first.
699+
var count int
700+
_, err = f.db.session.SelectBySql(
701+
"SELECT COUNT(*) FROM user_conversation_ext"+
702+
" WHERE uid=? AND space_id=? AND target_type=2 AND target_id=?",
703+
testutil.UID, spaceID, groupNo,
704+
).Load(&count)
705+
require.NoError(t, err)
706+
assert.Equal(t, 0, count,
707+
"first-time move-in must NOT create an ext row — sidebar materialization "+
708+
"is the single materialization site; RestoreAutoFollowThreadsTx is "+
709+
"strictly UPDATE")
710+
}
711+
430712
// ---------- Validation / Error Tests ----------
431713

432714
func TestCategory_CreateLimit(t *testing.T) {

modules/category/db_group_setting.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,27 @@ func (d *categoryDB) updateGroupSettingCategory(id int64, categoryID *string, ca
2626
return err
2727
}
2828

29+
// queryUserGroupsInSpace returns the user's groups in the given Space, each
30+
// annotated with the category_id the user has assigned (NULL if uncategorized).
31+
//
32+
// KNOWN ISSUE (issue #151 follow-up, NOT addressed in this PR): the SELECT
33+
// returns gs.category_id (the persisted field) without joining group_category.
34+
// If the user soft-deleted the assigned category before category_cleanup ran,
35+
// or a TOCTOU race produced a dangling reference (see
36+
// MoveGroupToCategory_TOCTOU_DanglingReference test in this module), this
37+
// query returns the stale category_id to the caller — the /category list API
38+
// then shows the group nested under a category that no longer exists, and the
39+
// client may use that id in a follow-up call which silently fails.
40+
//
41+
// Fix is the same shape as modules/message/db_group_category.go
42+
// QueryCategorySettingsByGroupNos: INNER/LEFT JOIN group_category gc ON
43+
// (gs.category_id, gs.uid) AND gc.status != 2, then SELECT gc.category_id so
44+
// dangling refs surface as NULL. Out of scope here because:
45+
// (a) issue #151 is scoped to the follow tab / sidebar materialization;
46+
// (b) the API consumer of this function may rely on stale ids to render the
47+
// "uncategorize-after-delete" affordance — needs PM input before
48+
// changing user-visible behaviour.
49+
// Track this as a follow-up; see PR description for rationale.
2950
func (d *categoryDB) queryUserGroupsInSpace(uid, spaceID string) ([]*userGroupInfo, error) {
3051
var results []*userGroupInfo
3152
_, err := d.session.SelectBySql(`

0 commit comments

Comments
 (0)