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

一、对方回复前,免费用户最多发送 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
@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"strconv"
"strings"
)
const defaultFreeDailyActiveChatLimit = 5
@@ -161,3 +162,49 @@ func (a *App) ensureMessageMembership(ctx context.Context, senderID int64, messa
}
return &messageMembershipGateError{Kind: kind}
}
const defaultUnansweredMessageLimit = 3
// unansweredMessageLimitError is the opening-message cap: until the other side
// says something back, a free account may only send so many. It is the mirror of
// the daily active-chat limit — that one bounds how many people you may open a
// conversation with, this one bounds how hard you may knock on one door.
type unansweredMessageLimitError struct{ Limit int }
func (e *unansweredMessageLimitError) Error() string {
return fmt.Sprintf("对方回复前最多发送 %d 条消息,开通会员后不受此限制", e.Limit)
}
func (a *App) resolveUnansweredMessageLimit(ctx context.Context, queryer rowQuerier) int {
var raw string
if err := queryer.QueryRowContext(ctx, `SELECT config_value FROM system_configs WHERE config_key='membership.free_unanswered_message_limit'`).Scan(&raw); err == nil {
if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil && parsed >= 0 {
return parsed
}
}
return defaultUnansweredMessageLimit
}
// reserveUnansweredMessage runs inside the send transaction, so two messages
// racing each other cannot both pass the last slot.
func (a *App) reserveUnansweredMessage(ctx context.Context, tx *sql.Tx, conversationID, senderID int64) error {
limit := a.resolveUnansweredMessageLimit(ctx, tx)
if limit <= 0 {
return nil
}
var mine, theirs int
// Recalled and removed messages still count as knocking: deleting your own
// message must not hand back a slot.
if err := tx.QueryRowContext(ctx, `SELECT
COALESCE(SUM(sender_id=?),0),COALESCE(SUM(sender_id<>?),0)
FROM im_messages WHERE conversation_id=?`, senderID, senderID, conversationID).Scan(&mine, &theirs); err != nil {
return err
}
if theirs > 0 || mine < limit {
return nil
}
if a.hasActiveMembership(ctx, senderID) {
return nil
}
return &unansweredMessageLimitError{Limit: limit}
}