Files
kefu/im/backend/internal/app/membership_limits.go
T
Your NameandClaude Opus 5 acd8933dbb 后端:资料距离、语音图片会员限制、免短信注册、AI 托管回复修复
- 单用户资料接口补上距离:此前只有推荐/附近列表会算距离,资料页和
  聊天头部因此无内容可显示。沿用同一套 haversine 与隐私开关。
- 语音/图片消息可限定会员发送,两个开关在管理端「运营配置」中修改
  (迁移 032)。校验放在 persistMessageContext,HTTP 与 WebSocket
  两条发送路径都覆盖;文本消息永不受限。
- 短信服务关闭时注册不再要求验证码:关掉之后没人能拿到验证码,继续
  要求就等于关闭注册通道。重置密码不做同样放宽,那里缺验证码等于
  凭手机号夺号。app/config 增加 smsVerification 供客户端决定表单形态。
- 修复 AI 托管账号之间不回复:原规则按「发送方是否托管账号」拦截,
  把真人操作测试号的正常对话也挡了。改为标记 worker 自己写入的回复,
  只对 AI 生成的消息跳过入队。
- ai.default_model_id 同时接受模型 ID 与名称,填名称时不再被 MySQL
  静默转成 0 而使配置失效。
- 聊天媒体留存管理与清理任务(迁移 033,两台线上均已应用)。

新增集成测试均针对真实 MySQL:会员限制、免短信注册、AI 入队规则、
默认模型解析、资料距离。

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

164 lines
5.5 KiB
Go

package app
import (
"context"
"database/sql"
"fmt"
"strconv"
)
const defaultFreeDailyActiveChatLimit = 5
type rowQuerier interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type dailyActiveChatLimitError struct {
Limit int
}
func (e *dailyActiveChatLimitError) Error() string {
return fmt.Sprintf("今日主动聊天人数已达上限(%d人),回复收到的消息不受此限制", e.Limit)
}
func (a *App) resolveDailyActiveChatLimit(ctx context.Context, queryer rowQuerier, userID int64) int {
var limit int
err := queryer.QueryRowContext(ctx, `SELECT p.daily_active_chat_limit
FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id
WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3)
ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, userID).Scan(&limit)
if err == nil {
return limit
}
var raw string
if err = queryer.QueryRowContext(ctx, `SELECT config_value FROM system_configs WHERE config_key='membership.free_daily_active_chat_limit'`).Scan(&raw); err == nil {
if parsed, parseErr := strconv.Atoi(raw); parseErr == nil && parsed >= 0 {
return parsed
}
}
return defaultFreeDailyActiveChatLimit
}
func dailyActiveChatQuotaView(limit, used int) map[string]any {
remaining := -1
unlimited := limit == 0
if !unlimited {
remaining = limit - used
if remaining < 0 {
remaining = 0
}
}
return map[string]any{
"limit": limit,
"remaining": remaining,
"unlimited": unlimited,
"used": used,
}
}
func (a *App) dailyActiveChatQuota(ctx context.Context, userID int64) map[string]any {
limit := a.resolveDailyActiveChatLimit(ctx, a.db, userID)
var used int
_ = a.db.QueryRowContext(ctx, `SELECT used_count FROM im_daily_active_chat_usage WHERE user_id=? AND usage_date=CURRENT_DATE()`, userID).Scan(&used)
return dailyActiveChatQuotaView(limit, used)
}
func (a *App) reserveDailyActiveChat(ctx context.Context, tx *sql.Tx, conversationID, senderID int64) error {
var user1ID, user2ID int64
err := tx.QueryRowContext(ctx, `SELECT user1_id,user2_id FROM im_direct_conversations WHERE conversation_id=?`, conversationID).Scan(&user1ID, &user2ID)
if err == sql.ErrNoRows {
return nil
}
if err != nil {
return err
}
targetUserID := user1ID
if senderID == user1ID {
targetUserID = user2ID
} else if senderID != user2ID {
return fmt.Errorf("不是会话成员")
}
var inboundToday int
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM im_messages
WHERE conversation_id=? AND sender_id=?
AND created_at>=CURRENT_DATE() AND created_at<DATE_ADD(CURRENT_DATE(),INTERVAL 1 DAY)
)`, conversationID, targetUserID).Scan(&inboundToday); err != nil {
return err
}
if inboundToday == 1 {
return nil
}
if _, err = tx.ExecContext(ctx, `INSERT IGNORE INTO im_daily_active_chat_usage(user_id,usage_date,used_count) VALUES(?,CURRENT_DATE(),0)`, senderID); err != nil {
return err
}
var used int
if err = tx.QueryRowContext(ctx, `SELECT used_count FROM im_daily_active_chat_usage WHERE user_id=? AND usage_date=CURRENT_DATE() FOR UPDATE`, senderID).Scan(&used); err != nil {
return err
}
var alreadyCounted int
if err = tx.QueryRowContext(ctx, `SELECT EXISTS(
SELECT 1 FROM im_daily_active_chat_targets
WHERE user_id=? AND target_user_id=? AND usage_date=CURRENT_DATE()
)`, senderID, targetUserID).Scan(&alreadyCounted); err != nil {
return err
}
if alreadyCounted == 1 {
return nil
}
limit := a.resolveDailyActiveChatLimit(ctx, tx, senderID)
if limit > 0 && used >= limit {
return &dailyActiveChatLimitError{Limit: limit}
}
if _, err = tx.ExecContext(ctx, `INSERT INTO im_daily_active_chat_targets(user_id,target_user_id,usage_date,conversation_id) VALUES(?,?,CURRENT_DATE(),?)`, senderID, targetUserID, conversationID); err != nil {
return err
}
_, err = tx.ExecContext(ctx, `UPDATE im_daily_active_chat_usage SET used_count=used_count+1 WHERE user_id=? AND usage_date=CURRENT_DATE()`, senderID)
return err
}
const (
messageTypeImage = 2
messageTypeVoice = 3
)
// messageMembershipGateError names the message kind the reader was blocked on,
// so the client can offer the right upgrade prompt instead of a generic error.
type messageMembershipGateError struct{ Kind string }
func (e *messageMembershipGateError) Error() string {
return fmt.Sprintf("发送%s消息需要开通会员", e.Kind)
}
// hasActiveMembership uses the same subscription row that /membership/status
// reports as "active", so the app never offers what the server will refuse.
// Every grant path — order, admin, gateway callback — writes that row.
func (a *App) hasActiveMembership(ctx context.Context, userID int64) bool {
var one int
return a.db.QueryRowContext(ctx, `SELECT 1 FROM subscriptions WHERE user_id=? AND status=1 AND started_at<=NOW(3) AND expires_at>NOW(3) LIMIT 1`, userID).Scan(&one) == nil
}
// messageMembershipGate reports whether one message type is members-only and
// what to call it. Both switches are edited from the admin console.
func (a *App) messageMembershipGate(ctx context.Context, messageType int) (string, bool) {
switch messageType {
case messageTypeImage:
return "图片", a.configBool(ctx, "membership.image_message_requires_vip", false)
case messageTypeVoice:
return "语音", a.configBool(ctx, "membership.voice_message_requires_vip", false)
}
return "", false
}
func (a *App) ensureMessageMembership(ctx context.Context, senderID int64, messageType int) error {
kind, gated := a.messageMembershipGate(ctx, messageType)
if !gated || a.hasActiveMembership(ctx, senderID) {
return nil
}
return &messageMembershipGateError{Kind: kind}
}