开场消息条数限制,附近按距离分档随机刷新
一、对方回复前,免费用户最多发送 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:
co-authored by
Claude Opus 5
parent
b42743c882
commit
f313979e88
@@ -460,7 +460,34 @@ func (a *App) messages(w http.ResponseWriter, r *http.Request) {
|
||||
if len(items) > 0 {
|
||||
nextAfterSeq = items[len(items)-1].Seq
|
||||
}
|
||||
reply(w, map[string]any{"items": items, "hasMore": hasMore, "nextBeforeSeq": nextBeforeSeq, "nextAfterSeq": nextAfterSeq})
|
||||
// The chat page shows how many opening messages are left before the other
|
||||
// side answers, so the reader learns the rule before a send is refused.
|
||||
reply(w, map[string]any{"items": items, "hasMore": hasMore, "nextBeforeSeq": nextBeforeSeq, "nextAfterSeq": nextAfterSeq,
|
||||
"unansweredQuota": a.unansweredQuotaView(r.Context(), id, current(r).ID)})
|
||||
}
|
||||
|
||||
// unansweredQuotaView reports the opening-message allowance for this
|
||||
// conversation: remaining -1 means "no limit applies", either because the other
|
||||
// side already replied, the reader is a member, or the switch is off.
|
||||
func (a *App) unansweredQuotaView(ctx context.Context, conversationID, userID int64) map[string]any {
|
||||
limit := a.resolveUnansweredMessageLimit(ctx, a.db)
|
||||
unlimited := map[string]any{"limit": limit, "remaining": -1, "unlimited": true}
|
||||
if limit <= 0 {
|
||||
return unlimited
|
||||
}
|
||||
var mine, theirs int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(SUM(sender_id=?),0),COALESCE(SUM(sender_id<>?),0) FROM im_messages WHERE conversation_id=?`,
|
||||
userID, userID, conversationID).Scan(&mine, &theirs); err != nil {
|
||||
return unlimited
|
||||
}
|
||||
if theirs > 0 || a.hasActiveMembership(ctx, userID) {
|
||||
return unlimited
|
||||
}
|
||||
remaining := limit - mine
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
return map[string]any{"limit": limit, "remaining": remaining, "unlimited": false}
|
||||
}
|
||||
|
||||
func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -492,6 +519,11 @@ func (a *App) sendMessageHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
fail(w, http.StatusPaymentRequired, 30006, gateErr.Error())
|
||||
return
|
||||
}
|
||||
var knockErr *unansweredMessageLimitError
|
||||
if errors.As(err, &knockErr) {
|
||||
fail(w, http.StatusPaymentRequired, 30007, knockErr.Error())
|
||||
return
|
||||
}
|
||||
fail(w, 400, 30004, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -616,6 +648,13 @@ func (a *App) persistMessageContext(ctx context.Context, conversationID, senderI
|
||||
if err = a.reserveDailyActiveChat(ctx, tx, conversationID, senderID); err != nil {
|
||||
return item, nil, err
|
||||
}
|
||||
// The AI worker answers on behalf of a managed account; its reply is the
|
||||
// answer, never an unanswered knock.
|
||||
if !isAIGenerated(ctx) {
|
||||
if err = a.reserveUnansweredMessage(ctx, tx, conversationID, senderID); err != nil {
|
||||
return item, nil, err
|
||||
}
|
||||
}
|
||||
seq := lastSeq + 1
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO im_messages(conversation_id,seq,sender_id,client_msg_id,message_type,body)VALUES(?,?,?,?,?,?)`, conversationID, seq, senderID, clientMsgID, messageType, body)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user