一、对方回复前,免费用户最多发送 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>
109 lines
3.3 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|