Files
kefu/im/backend/internal/app/nearby_shuffle_integration_test.go
Your NameandClaude Opus 5 f313979e88 开场消息条数限制,附近按距离分档随机刷新
一、对方回复前,免费用户最多发送 N 条(默认 3,迁移 034,管理端可改,
0 表示不限)。约束在 persistMessageContext 的事务内,HTTP 与 WebSocket
两条发送路径都覆盖,并发也不会挤过最后一个名额。要点:
- 对方一旦开口,限制永久解除;会员始终不受限。
- 撤回自己的消息不会换回名额,否则删了重发即可绕过。
- AI 托管账号的回复是「回答」不是「敲门」,不计入限制。
- 被拒时返回 30007 + HTTP 402,与会员媒体限制区分,客户端据此引导开通。
- 消息列表接口附带 unansweredQuota,聊天页在发送失败之前就把剩余条数
  告诉用户。

二、附近下拉刷新按距离分档随机。原先带 seed 的分支排在最前,导致附近
一旦刷新就退化成全城纯随机、距离排序被完全丢弃。现在按 1 公里分档,
档内用同一 seed 做确定性洗牌:刷新会换人,但近处的人永远排在远处之前,
同一 seed 下分页顺序稳定,不会重复或漏人。

两个既有测试显式关闭了开场限制——它们测的是入队规则和媒体会员限制,
连发消息只是手段。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:21:19 +08:00

109 lines
3.3 KiB
Go

package app
import (
"encoding/json"
"fmt"
"testing"
)
// Pull to refresh on 附近 should bring different faces without sending the
// reader across town. Before this, a seed replaced the distance ordering
// entirely and the list became a plain shuffle of the whole city.
func TestNearbyShuffleStaysWithinDistanceBandsMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
// Viewer at the city centre; then three people ~200m away and three ~40km away.
const centreLat, centreLng = 22.5431, 114.0579
if _, err := db.Exec(`INSERT INTO user_location_states(user_id,city_code,latitude,longitude) VALUES(1,'440300',?,?)`, centreLat, centreLng); err != nil {
t.Fatal(err)
}
near := map[int64]bool{}
for id := int64(2); id <= 7; id++ {
if id > 3 {
if _, err := db.Exec(`INSERT INTO users(id,public_id,password_hash) VALUES(?,CONCAT('NEARBY',?),'!NO_LOGIN')`, id, id); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`INSERT INTO user_profiles(user_id,nickname) VALUES(?,CONCAT('附近',?))`,
`INSERT INTO user_privacy_settings(user_id) VALUES(?)`,
} {
args := []any{id}
if statement == `INSERT INTO user_profiles(user_id,nickname) VALUES(?,CONCAT('附近',?))` {
args = append(args, id)
}
if _, err := db.Exec(statement, args...); err != nil {
t.Fatal(err)
}
}
}
offset := 0.002 * float64(id) // ~200m steps
if id >= 5 {
offset = 0.36 * float64(id) // ~40km and beyond
} else {
near[id] = true
}
if _, err := db.Exec(`INSERT INTO user_location_states(user_id,city_code,latitude,longitude) VALUES(?,'440300',?,?)`, id, centreLat+offset, centreLng); err != nil {
t.Fatal(err)
}
}
page := func(seed int) []int64 {
t.Helper()
var payload struct {
Items []struct {
ID int64 `json:"id"`
} `json:"items"`
}
query := "/api/v1/nearby/users?page=1&pageSize=20"
if seed > 0 {
query += fmt.Sprintf("&seed=%d", seed)
}
if err := json.Unmarshal(imTestCall(t, a.nearby, 1, "GET", query, "", 200), &payload); err != nil {
t.Fatal(err)
}
ids := []int64{}
for _, item := range payload.Items {
ids = append(ids, item.ID)
}
return ids
}
t.Run("the near band always comes first, whatever the seed", func(t *testing.T) {
for _, seed := range []int{0, 7, 12345, 2147483647} {
ids := page(seed)
if len(ids) < 6 {
t.Fatalf("seed %d 返回 %v", seed, ids)
}
for index, id := range ids[:3] {
if !near[id] {
t.Fatalf("seed %d 第 %d 位是远处的 %d: %v", seed, index+1, id, ids)
}
}
for _, id := range ids[3:] {
if near[id] {
t.Fatalf("seed %d 把近处的人排到了后面: %v", seed, ids)
}
}
}
})
t.Run("different seeds reorder people inside the band", func(t *testing.T) {
seen := map[string]bool{}
for seed := 1; seed <= 40; seed++ {
ids := page(seed)
seen[fmt.Sprint(ids[:3])] = true
}
if len(seen) < 2 {
t.Fatalf("刷新应当换顺序,实际只有一种排列: %v", seen)
}
})
t.Run("one seed is stable, so paging cannot repeat or skip a row", func(t *testing.T) {
first := page(99)
for attempt := 0; attempt < 3; attempt++ {
if fmt.Sprint(page(99)) != fmt.Sprint(first) {
t.Fatalf("同一 seed 必须给出同一顺序: %v vs %v", page(99), first)
}
}
})
}