开场消息条数限制,附近按距离分档随机刷新

一、对方回复前,免费用户最多发送 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>
This commit is contained in:
Your Name
2026-09-04 18:21:19 +08:00
co-authored by Claude Opus 5
parent b42743c882
commit f313979e88
8 changed files with 386 additions and 2 deletions
+14 -1
View File
@@ -208,6 +208,10 @@ func (a *App) userProfile(w http.ResponseWriter, r *http.Request) {
reply(w, item)
}
// One kilometre: fine enough that a refresh never trades a neighbour for someone
// across the city, coarse enough that there is a real pool to shuffle.
const nearbyDistanceBandMetres = "1000"
func (a *App) discover(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, false) }
func (a *App) nearby(w http.ResponseWriter, r *http.Request) { a.discoverList(w, r, true) }
@@ -308,7 +312,16 @@ func (a *App) discoverList(w http.ResponseWriter, r *http.Request, byDistance bo
EXISTS(SELECT 1 FROM user_follows f WHERE f.user_id=? AND f.target_user_id=u.id),EXISTS(SELECT 1 FROM user_likes x WHERE x.user_id=? AND x.target_user_id=u.id)
` + fromWhere
args := append([]any{who.ID, who.ID}, filterArgs...)
if shuffleSeed > 0 {
distanceExpression := `CASE WHEN privacy.distance_visible=1 THEN COALESCE(ST_Distance_Sphere(POINT(l.longitude,l.latitude),POINT(?,?)),1000000000000000) ELSE 1000000000000000 END`
if byDistance && shuffleSeed > 0 && myLat.Valid && myLng.Valid {
// Pull to refresh on 附近 should bring different faces without sending the
// reader across town: order by a coarse distance band first, then shuffle
// inside it. Everyone within the same kilometre is equally "nearby", so
// that is the granularity worth randomising over. Hidden distances land
// in the last band, as they do without a seed.
query += ` ORDER BY FLOOR(` + distanceExpression + `/` + nearbyDistanceBandMetres + `) ASC,CRC32(CONCAT(u.id,':',?)),u.id DESC LIMIT ? OFFSET ?`
args = append(args, myLng.Float64, myLat.Float64, shuffleSeed)
} else if shuffleSeed > 0 {
// CRC32 produces a deterministic shuffle for this seed. The client keeps
// the seed while paging, so adjacent pages cannot overlap or skip rows.
query += ` ORDER BY CRC32(CONCAT(u.id,':',?)),u.id DESC LIMIT ? OFFSET ?`