feat: add redis cache and fanout support

This commit is contained in:
richjohnrambo
2026-08-25 20:54:03 +08:00
parent 07376c70e3
commit f900a08a01
68 changed files with 3949 additions and 5696 deletions
+90 -15
View File
@@ -60,6 +60,7 @@ const (
imVerificationExpiresInSeconds = 300
imVerificationResendAfterSeconds = 60
imVerificationMaxAttempts = 5
imVerificationCreateRateWindow = time.Hour
imActionTokenExpiresInSeconds = 300
imIndependentPasswordMaxAttempts = 5
imIndependentPasswordLockSeconds = 300
@@ -380,7 +381,7 @@ type imMessageRevisionRequest struct {
type imFavoritesBatchCreateRequest struct {
Messages []struct {
Topic string `json:"topic"`
Seq int `json:"seq,string"`
Seq int64 `json:"seq,string"`
} `json:"messages"`
}
@@ -479,7 +480,7 @@ type imGroupFileCreateRequest struct {
FileType string `json:"file_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
SourceTopic string `json:"source_topic,omitempty"`
SourceSeq int `json:"source_seq,omitempty"`
SourceSeq int64 `json:"source_seq,omitempty"`
}
type imGroupAnnouncementPutRequest struct {
@@ -874,6 +875,18 @@ func serveIMVerificationCodes(wrt http.ResponseWriter, req *http.Request) {
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(tenant.ID, scene, phone, "phone")
if allowed, _, ttl, err := store.AllowRate(tenant.ID, "verification:create:"+scene+":"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im verification create: redis rate limit failed", tenant.Code, scene, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(tenant.ID, scene, phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
logs.Warn.Println("im verification create: active lookup failed", tenant.Code, scene, err)
@@ -3342,7 +3355,7 @@ func serveIMMessageContext(wrt http.ResponseWriter, req *http.Request) {
}
before := parseIMLimit(req.URL.Query().Get("before"), 20, 100)
after := parseIMLimit(req.URL.Query().Get("after"), 20, 100)
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - before, Before: seq + after + 1, Limit: before + after + 1})
msgs, err := store.Messages.GetAll(principal.TenantID, topic, principal.Uid, &types.QueryOpt{Since: seq - int64(before), Before: seq + int64(after) + 1, Limit: before + after + 1})
if err != nil {
writeIMRelationError(wrt, req, err)
return
@@ -3599,7 +3612,7 @@ func serveIMConversationContext(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
return
}
peer := peerFromP2PTopic(topic, principal.Uid)
topic, peer := normalizeP2PConversationTopic(topic, principal.Uid)
if topic == "" || peer.IsZero() {
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
@@ -3617,6 +3630,7 @@ func serveIMConversationSettings(wrt http.ResponseWriter, req *http.Request, pri
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "topic"})
return
}
topic = normalizeConversationSettingsTopic(topic, principal.Uid)
switch req.Method {
case http.MethodGet:
settings, err := store.Conversations.GetSettings(principal.TenantID, principal.Uid, topic)
@@ -4043,6 +4057,11 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
roleFilter := strings.TrimSpace(req.URL.Query().Get("role"))
eligibleRole := strings.TrimSpace(req.URL.Query().Get("eligible_role"))
muteEligible := parseIMBool(req.URL.Query().Get("mute_eligible"))
cacheKey := groupMemberPageCacheKey(topic, principal.Uid, selfAccess, query, roleFilter, eligibleRole, limit, cursor, mentionableOnly, muteEligible)
if cached, ok := store.GetCachedGroupMemberPage(principal.TenantID, cacheKey); ok {
writeIMSuccess(wrt, req, cached)
return
}
members, err := store.Topics.GetUsers(principal.TenantID, topic, &types.QueryOpt{Limit: 1000})
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
@@ -4093,12 +4112,14 @@ func serveIMGroupMembers(wrt http.ResponseWriter, req *http.Request, topic strin
permissions := map[string]any{
"can_mention_all": selfAccess.IsAdmin(),
}
writeIMSuccess(wrt, req, map[string]any{
payload := map[string]any{
"topic": topic,
"items": items,
"next_cursor": nextCursor,
"permissions": permissions,
})
}
store.CacheGroupMemberPage(principal.TenantID, cacheKey, payload)
writeIMSuccess(wrt, req, payload)
}
func serveIMGroupDetail(wrt http.ResponseWriter, req *http.Request, topic string) {
@@ -5767,6 +5788,18 @@ func currentUserPhone(tenantID types.TenantID, uid types.Uid) (string, error) {
func createSensitiveChallenge(wrt http.ResponseWriter, req *http.Request, principal *imPrincipal, action, phone string) {
now := time.Now().UTC().Round(time.Millisecond)
phoneHash := hashIMSecret(principal.TenantID, "sensitive_action", phone, "phone")
if allowed, _, ttl, err := store.AllowRate(principal.TenantID, "verification:create:sensitive_action:"+phoneHash, imVerificationMaxAttempts, imVerificationCreateRateWindow); err != nil {
logs.Warn.Println("im sensitive verification create: redis rate limit failed", principal.TenantID, err)
} else if !allowed {
retryAfter := int(ttl.Seconds())
if retryAfter < 1 {
retryAfter = int(imVerificationCreateRateWindow.Seconds())
}
writeIMError(wrt, req, http.StatusTooManyRequests, 30020, imReasonVerificationRate, "verification rate limited", map[string]any{
"retry_after_seconds": retryAfter,
})
return
}
active, err := store.Verifications.GetActive(principal.TenantID, "sensitive_action", phoneHash, now)
if err != nil && !errors.Is(err, types.ErrNotFound) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
@@ -6712,7 +6745,23 @@ func groupMemberMatches(item map[string]any, query string) bool {
return false
}
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int, allowAdmin bool) (*types.Message, bool, error) {
func groupMemberPageCacheKey(topic string, uid types.Uid, access types.AccessMode, query, roleFilter, eligibleRole string,
limit, cursor int, mentionableOnly, muteEligible bool) string {
return strings.Join([]string{
topic,
uid.UserId(),
access.String(),
strings.ToLower(strings.TrimSpace(query)),
strings.TrimSpace(roleFilter),
strings.TrimSpace(eligibleRole),
strconv.Itoa(limit),
strconv.Itoa(cursor),
strconv.FormatBool(mentionableOnly),
strconv.FormatBool(muteEligible),
}, "|")
}
func authorizeIMMessageMutation(principal *imPrincipal, topic string, seq int64, allowAdmin bool) (*types.Message, bool, error) {
if principal == nil {
return nil, false, types.ErrPermissionDenied
}
@@ -8385,7 +8434,7 @@ func cleanTenantCodeHint(code string) string {
return code
}
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int, map[string]any, error) {
func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target types.AbuseTarget) (string, types.Uid, string, int64, map[string]any, error) {
targetType := strings.ToLower(strings.TrimSpace(target.Type))
snapshot := map[string]any{"type": targetType}
switch targetType {
@@ -8413,7 +8462,7 @@ func normalizeIMAbuseTarget(tenantID types.TenantID, reporter types.Uid, target
return targetType, 0, topic, 0, snapshot, nil
case "message":
topic := strings.TrimSpace(target.Topic)
seq, err := strconv.Atoi(strings.TrimSpace(target.Seq))
seq, err := strconv.ParseInt(strings.TrimSpace(target.Seq), 10, 64)
if topic == "" || seq <= 0 || err != nil {
return "", 0, "", 0, nil, types.ErrMalformed
}
@@ -8476,7 +8525,7 @@ func groupInvitationResourcePath(path string) (string, string, bool) {
return strings.TrimSpace(invitationID), strings.Trim(tail, "/"), true
}
func messageReceiptsPath(path string) (string, int, bool) {
func messageReceiptsPath(path string) (string, int64, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
@@ -8491,7 +8540,7 @@ func messageReceiptsPath(path string) (string, int, bool) {
if !ok || suffix != "receipts" {
return "", 0, false
}
seq, err := strconv.Atoi(seqValue)
seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil {
return topic, 0, true
}
@@ -8531,7 +8580,7 @@ func topicFromPathWithSuffix(path, prefix, suffix string) string {
return strings.Trim(rest, "/")
}
func messageContextPath(path string) (string, int, bool) {
func messageContextPath(path string) (string, int64, bool) {
const prefix = "/v1/im/topics/"
idx := strings.Index(path, prefix)
if idx < 0 {
@@ -8542,14 +8591,14 @@ func messageContextPath(path string) (string, int, bool) {
if len(parts) != 4 || parts[1] != "messages" || parts[3] != "context" {
return "", 0, false
}
seq, err := strconv.Atoi(parts[2])
seq, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || seq <= 0 {
return "", 0, false
}
return parts[0], seq, true
}
func messageActionPath(path, action string) (string, int, bool) {
func messageActionPath(path, action string) (string, int64, bool) {
const marker = "/v1/im/topics/"
idx := strings.Index(path, marker)
if idx < 0 {
@@ -8564,7 +8613,7 @@ func messageActionPath(path, action string) (string, int, bool) {
if !ok || suffix != action {
return "", 0, false
}
seq, err := strconv.Atoi(seqValue)
seq, err := strconv.ParseInt(seqValue, 10, 64)
if err != nil {
return topic, 0, true
}
@@ -8756,3 +8805,29 @@ func peerFromP2PTopic(topic string, uid types.Uid) types.Uid {
}
return types.ZeroUid
}
func normalizeP2PConversationTopic(topic string, uid types.Uid) (string, types.Uid) {
topic = strings.TrimSpace(topic)
if len(topic) < 3 || uid.IsZero() {
return "", types.ZeroUid
}
if types.GetTopicCat(topic) == types.TopicCatMe {
peer := types.ParseUserId(topic)
if peer.IsZero() || peer == uid {
return "", types.ZeroUid
}
return uid.P2PName(peer), peer
}
peer := peerFromP2PTopic(topic, uid)
if peer.IsZero() {
return "", types.ZeroUid
}
return topic, peer
}
func normalizeConversationSettingsTopic(topic string, uid types.Uid) string {
if normalized, peer := normalizeP2PConversationTopic(topic, uid); !peer.IsZero() {
return normalized
}
return strings.TrimSpace(topic)
}