feat: complete IM P0 and P1 reliability work

This commit is contained in:
richjohnrambo
2026-08-17 13:43:27 +08:00
parent ddf0f7a55e
commit f2c229135b
43 changed files with 4512 additions and 1181 deletions
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"time"
adapter "github.com/tinode/chat/server/db"
"github.com/tinode/chat/server/store/types"
)
type HTTPInfrastructurePersistenceInterface interface {
BeginIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error)
CompleteIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
AbortIdempotency(types.TenantID, *types.HTTPIdempotencyRecord) error
CreateEvent(types.TenantID, *types.BusinessEvent) error
ListPendingEvents(types.TenantID, int, time.Time) ([]types.BusinessEvent, error)
MarkEventDelivered(types.TenantID, string, time.Time) error
}
type httpInfrastructureMapper struct{}
var HTTPInfrastructure HTTPInfrastructurePersistenceInterface = httpInfrastructureMapper{}
func tenantHTTPInfrastructureAdapter(tenantID types.TenantID) (adapter.TenantHTTPInfrastructureAdapter, error) {
business, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
infrastructure, ok := business.(adapter.TenantHTTPInfrastructureAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return infrastructure, nil
}
func (httpInfrastructureMapper) BeginIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, false, err
}
return infrastructure.TenantHTTPIdempotencyBegin(tenantID, record)
}
func (httpInfrastructureMapper) CompleteIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyComplete(tenantID, record)
}
func (httpInfrastructureMapper) AbortIdempotency(tenantID types.TenantID, record *types.HTTPIdempotencyRecord) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantHTTPIdempotencyAbort(tenantID, record)
}
func (httpInfrastructureMapper) CreateEvent(tenantID types.TenantID, event *types.BusinessEvent) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantBusinessEventCreate(tenantID, event)
}
func (httpInfrastructureMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.BusinessEvent, error) {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return nil, err
}
return infrastructure.TenantBusinessEventListPending(tenantID, limit, now)
}
func (httpInfrastructureMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error {
infrastructure, err := tenantHTTPInfrastructureAdapter(tenantID)
if err != nil {
return err
}
return infrastructure.TenantBusinessEventMarkDelivered(tenantID, eventID, now)
}
+241 -10
View File
@@ -129,6 +129,7 @@ type storeObj struct{}
// TenantsPersistenceInterface defines tenant registry lookups used before authentication.
type TenantsPersistenceInterface interface {
GetByCode(code string) (*types.Tenant, error)
GetByID(id types.TenantID) (*types.Tenant, error)
}
type tenantsMapper struct{}
@@ -145,6 +146,18 @@ func (tenantsMapper) GetByCode(code string) (*types.Tenant, error) {
return tenantAdp.TenantGetByCode(code)
}
// GetByID resolves an internal tenant ID to its registry record.
func (tenantsMapper) GetByID(id types.TenantID) (*types.Tenant, error) {
if !id.IsValid() {
return nil, types.ErrMalformed
}
tenantAdp, ok := adp.(adapter.TenantAdapter)
if !ok {
return nil, types.ErrUnsupported
}
return tenantAdp.TenantGetByID(id)
}
func tenantAuthAdapter(tenantID types.TenantID) (adapter.TenantAuthAdapter, error) {
if !tenantID.IsValid() {
return nil, types.ErrMalformed
@@ -1080,6 +1093,7 @@ func (callsMapper) List(tenantID types.TenantID, uid types.Uid, topic, cursor st
}
type GroupBusinessPersistenceInterface interface {
CreateGroup(types.TenantID, *types.Topic, types.Uid, any, *types.GroupSettings, []types.Uid, any, time.Time) error
CreateSettings(types.TenantID, *types.GroupSettings) (*types.GroupSettings, error)
GetSettings(types.TenantID, string) (*types.GroupSettings, error)
UpdateSettings(types.TenantID, string, types.GroupSettingsPatch) (*types.GroupSettings, error)
@@ -1090,19 +1104,41 @@ type GroupBusinessPersistenceInterface interface {
CreateInvitation(types.TenantID, *types.GroupInvitation) (*types.GroupInvitation, error)
CreateJoinRequest(types.TenantID, *types.GroupJoinRequest) (*types.GroupJoinRequest, error)
ListJoinRequests(types.TenantID, string, string, string, int) ([]types.GroupJoinRequest, string, error)
ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, time.Time) (*types.GroupJoinRequest, error)
ResolveJoinRequest(types.TenantID, string, string, types.Uid, string, string, int, any, time.Time) (*types.GroupJoinRequest, error)
LatestAnnouncement(types.TenantID, string) (*types.GroupAnnouncement, error)
PutAnnouncement(types.TenantID, *types.GroupAnnouncement, int) (*types.GroupAnnouncement, error)
ReadAnnouncement(types.TenantID, string, string, types.Uid, time.Time) error
ListMutes(types.TenantID, string, string, int) ([]types.GroupMute, string, error)
UpsertMute(types.TenantID, *types.GroupMute) (*types.GroupMute, error)
DeleteMute(types.TenantID, string, types.Uid, types.Uid, time.Time) error
InviteMember(types.TenantID, string, types.Uid, types.Uid, string, string, any, time.Time) (bool, error)
Leave(types.TenantID, string, types.Uid, time.Time) error
UpdateRole(types.TenantID, string, types.Uid, types.Uid, string, types.AccessMode, time.Time) error
TransferOwner(types.TenantID, string, types.Uid, types.Uid, int, time.Time) error
CanPost(types.TenantID, string, types.Uid, bool, time.Time) (bool, error)
ListPendingEvents(types.TenantID, int, time.Time) ([]types.GroupEvent, error)
MarkEventDelivered(types.TenantID, string, time.Time) error
}
type groupBusinessMapper struct{}
var GroupBusiness GroupBusinessPersistenceInterface
func (groupBusinessMapper) CreateGroup(tenantID types.TenantID, topic *types.Topic, owner types.Uid, ownerPrivate any,
settings *types.GroupSettings, members []types.Uid, memberPrivate any, now time.Time) error {
if topic == nil || topic.TenantID != tenantID || owner.IsZero() || settings == nil || settings.Topic != topic.Id {
return types.ErrMalformed
}
topic.InitTimes()
topic.TouchedAt = topic.CreatedAt
topic.Owner = owner.String()
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupCreate(tenantID, topic, owner, ownerPrivate, settings, members, memberPrivate, now)
}
func (groupBusinessMapper) CreateSettings(tenantID types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) {
if settings == nil || settings.Topic == "" || settings.OwnerUserID.IsZero() || settings.CreatedBy.IsZero() {
return nil, types.ErrMalformed
@@ -1219,7 +1255,7 @@ func (groupBusinessMapper) ListJoinRequests(tenantID types.TenantID, topic, stat
return tenantAdp.TenantGroupJoinRequestList(tenantID, strings.TrimSpace(topic), strings.TrimSpace(status), strings.TrimSpace(cursor), limit)
}
func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, requestID string, actor types.Uid, action, rejectReason string, version int, now time.Time) (*types.GroupJoinRequest, error) {
func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, requestID string, actor types.Uid, action, rejectReason string, version int, memberPrivate any, now time.Time) (*types.GroupJoinRequest, error) {
if strings.TrimSpace(topic) == "" || strings.TrimSpace(requestID) == "" || actor.IsZero() || now.IsZero() {
return nil, types.ErrMalformed
}
@@ -1227,7 +1263,7 @@ func (groupBusinessMapper) ResolveJoinRequest(tenantID types.TenantID, topic, re
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, now)
return tenantAdp.TenantGroupJoinRequestResolve(tenantID, strings.TrimSpace(topic), strings.TrimSpace(requestID), actor, strings.TrimSpace(action), strings.TrimSpace(rejectReason), version, memberPrivate, now)
}
func (groupBusinessMapper) LatestAnnouncement(tenantID types.TenantID, topic string) (*types.GroupAnnouncement, error) {
@@ -1299,6 +1335,87 @@ func (groupBusinessMapper) DeleteMute(tenantID types.TenantID, topic string, uid
return tenantAdp.TenantGroupMuteDelete(tenantID, strings.TrimSpace(topic), uid, actor, now)
}
func (groupBusinessMapper) InviteMember(tenantID types.TenantID, topic string, inviter, target types.Uid,
message, idempotencyKey string, private any, now time.Time) (bool, error) {
if strings.TrimSpace(topic) == "" || inviter.IsZero() || target.IsZero() || now.IsZero() {
return false, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return false, err
}
return tenantAdp.TenantGroupInviteMember(tenantID, strings.TrimSpace(topic), inviter, target,
strings.TrimSpace(message), strings.TrimSpace(idempotencyKey), private, now)
}
func (groupBusinessMapper) Leave(tenantID types.TenantID, topic string, uid types.Uid, now time.Time) error {
if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupLeave(tenantID, strings.TrimSpace(topic), uid, now)
}
func (groupBusinessMapper) UpdateRole(tenantID types.TenantID, topic string, actor, target types.Uid,
role string, modeGiven types.AccessMode, now time.Time) error {
if strings.TrimSpace(topic) == "" || actor.IsZero() || target.IsZero() || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupRoleUpdate(tenantID, strings.TrimSpace(topic), actor, target, strings.TrimSpace(role), modeGiven, now)
}
func (groupBusinessMapper) TransferOwner(tenantID types.TenantID, topic string, previousOwner, newOwner types.Uid,
version int, now time.Time) error {
if strings.TrimSpace(topic) == "" || previousOwner.IsZero() || newOwner.IsZero() || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupOwnerTransfer(tenantID, strings.TrimSpace(topic), previousOwner, newOwner, version, now)
}
func (groupBusinessMapper) CanPost(tenantID types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
if strings.TrimSpace(topic) == "" || uid.IsZero() || now.IsZero() {
return false, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return false, err
}
return tenantAdp.TenantGroupCanPost(tenantID, strings.TrimSpace(topic), uid, admin, now)
}
func (groupBusinessMapper) ListPendingEvents(tenantID types.TenantID, limit int, now time.Time) ([]types.GroupEvent, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, err
}
return tenantAdp.TenantGroupEventListPending(tenantID, limit, now)
}
func (groupBusinessMapper) MarkEventDelivered(tenantID types.TenantID, eventID string, now time.Time) error {
if strings.TrimSpace(eventID) == "" || now.IsZero() {
return types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return err
}
return tenantAdp.TenantGroupEventMarkDelivered(tenantID, strings.TrimSpace(eventID), now)
}
type WorkbenchPersistenceInterface interface {
Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error)
}
@@ -1724,6 +1841,10 @@ type DevicePushTokenPersistenceInterface interface {
Upsert(tenantID types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error)
}
type DevicePushTokenQueryInterface interface {
GetAll(tenantID types.TenantID, uid ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
}
type devicePushTokenMapper struct{}
var DevicePushTokens DevicePushTokenPersistenceInterface
@@ -1740,6 +1861,20 @@ func (devicePushTokenMapper) Upsert(tenantID types.TenantID, token *types.Device
return tenantAdp.TenantDevicePushTokenUpsert(tenantID, token)
}
func (devicePushTokenMapper) GetAll(tenantID types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
tenantAdp, err := tenantBusinessAdapter(tenantID)
if err != nil {
return nil, 0, err
}
reader, ok := tenantAdp.(interface {
TenantDevicePushTokenGetAll(types.TenantID, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error)
})
if !ok {
return map[types.Uid][]types.DeviceDef{}, 0, nil
}
return reader.TenantDevicePushTokenGetAll(tenantID, uids...)
}
type SafetyPersistenceInterface interface {
CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error)
ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error)
@@ -1863,7 +1998,7 @@ func (profileMapper) GetPrivacy(tenantID types.TenantID, uid types.Uid) (*types.
}
func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch types.UserPrivacyPatch) (*types.UserPrivacySettings, error) {
if uid.IsZero() || patch.Version <= 0 {
if uid.IsZero() || patch.Version <= 0 || !validUserPrivacyPatch(patch) {
return nil, types.ErrMalformed
}
tenantAdp, err := tenantBusinessAdapter(tenantID)
@@ -1873,6 +2008,31 @@ func (profileMapper) UpdatePrivacy(tenantID types.TenantID, uid types.Uid, patch
return tenantAdp.TenantUserPrivacyUpdate(tenantID, uid, patch)
}
func validUserPrivacyPatch(patch types.UserPrivacyPatch) bool {
if patch.AddMeMethods == nil && patch.ProfileVisibility == nil && patch.LastSeenVisibility == nil && patch.ReadReceipts == nil {
return false
}
for method := range patch.AddMeMethods {
switch method {
case "account", "phone", "qr", "group":
default:
return false
}
}
validVisibility := func(value *string) bool {
if value == nil {
return true
}
switch strings.TrimSpace(*value) {
case "everyone", "friends", "nobody":
return true
default:
return false
}
}
return validVisibility(patch.ProfileVisibility) && validVisibility(patch.LastSeenVisibility)
}
func validUserProfilePatch(patch types.UserProfilePatch) bool {
if patch.AvatarFileID == nil && patch.Nickname == nil && patch.BirthDate == nil && patch.Signature == nil && patch.Gender == nil && patch.Region == nil {
return false
@@ -2321,20 +2481,77 @@ func normalizeClientMsgID(head types.KVMap) (string, error) {
func validateMessageHeaders(head types.KVMap) error {
if schema, ok := head["x-im-schema"]; ok {
value, ok := schema.(string)
if !ok || !validIMMessageHeaderToken(value, 32) {
if !validIMMessageSchema(schema) {
return types.ErrMalformed
}
}
if msgType, ok := head["type"]; ok {
value, ok := msgType.(string)
if !ok || !validIMMessageType(value) {
legacyType, hasLegacyType := messageHeaderString(head, "type")
productType, hasProductType := messageHeaderString(head, "x-im-type")
if raw, ok := head["type"]; ok && (!hasLegacyType || !validIMMessageType(legacyType)) {
_ = raw
return types.ErrMalformed
}
if raw, ok := head["x-im-type"]; ok && (!hasProductType || !validIMMessageType(productType)) {
_ = raw
return types.ErrMalformed
}
if hasLegacyType && hasProductType {
if canonicalIMMessageType(legacyType) != canonicalIMMessageType(productType) {
return types.ErrMalformed
}
}
return nil
}
func validIMMessageSchema(value any) bool {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed) == "1"
case json.Number:
parsed, err := typed.Int64()
return err == nil && parsed == 1
case float64:
return typed == 1
case float32:
return typed == 1
case int:
return typed == 1
case int8:
return typed == 1
case int16:
return typed == 1
case int32:
return typed == 1
case int64:
return typed == 1
case uint:
return typed == 1
case uint8:
return typed == 1
case uint16:
return typed == 1
case uint32:
return typed == 1
case uint64:
return typed == 1
default:
return false
}
}
func messageHeaderString(head types.KVMap, key string) (string, bool) {
value, ok := head[key]
if !ok {
return "", false
}
text, ok := value.(string)
if !ok {
return "", false
}
text = strings.TrimSpace(text)
return text, text != ""
}
func validIMMessageHeaderToken(value string, max int) bool {
value = strings.TrimSpace(value)
if value == "" || len(value) > max {
@@ -2351,13 +2568,27 @@ func validIMMessageHeaderToken(value string, max int) bool {
func validIMMessageType(value string) bool {
switch strings.TrimSpace(value) {
case "text", "image", "file", "audio", "video", "location", "contact", "card", "call", "system":
case "text", "image", "file", "audio", "voice", "video", "location", "contact", "contact_card",
"card", "link", "forward_bundle", "sticker", "call", "system", "system_event":
return true
default:
return false
}
}
func canonicalIMMessageType(value string) string {
switch strings.TrimSpace(value) {
case "audio":
return "voice"
case "contact":
return "contact_card"
case "system":
return "system_event"
default:
return strings.TrimSpace(value)
}
}
// DeleteList deletes multiple messages defined by a list of ranges.
func (messagesMapper) DeleteList(tenantID types.TenantID, topic string, delID int, forUser types.Uid, msgDelAge time.Duration, ranges []types.Range) error {
tenantAdp, err := tenantBusinessAdapter(tenantID)
+28
View File
@@ -1,6 +1,7 @@
package store
import (
"encoding/json"
"testing"
"github.com/tinode/chat/server/store/types"
@@ -11,3 +12,30 @@ func TestForTenantRejectsZero(t *testing.T) {
t.Fatalf("ForTenant(0) = (%v, %v), want (nil, malformed)", scoped, err)
}
}
func TestValidateMessageHeadersProductContract(t *testing.T) {
valid := []types.KVMap{
{"x-im-schema": float64(1), "x-im-type": "text"},
{"x-im-schema": json.Number("1"), "x-im-type": "voice"},
{"x-im-schema": "1", "x-im-type": "contact_card"},
{"x-im-schema": 1, "type": "audio", "x-im-type": "voice"},
{"type": "system"},
}
for _, head := range valid {
if err := validateMessageHeaders(head); err != nil {
t.Fatalf("validateMessageHeaders(%v) = %v, want nil", head, err)
}
}
invalid := []types.KVMap{
{"x-im-schema": float64(2), "x-im-type": "text"},
{"x-im-schema": true, "x-im-type": "text"},
{"x-im-schema": float64(1), "x-im-type": "unknown"},
{"type": "text", "x-im-type": "image"},
}
for _, head := range invalid {
if err := validateMessageHeaders(head); err != types.ErrMalformed {
t.Fatalf("validateMessageHeaders(%v) = %v, want malformed", head, err)
}
}
}
+16
View File
@@ -180,3 +180,19 @@ type GroupMute struct {
MutedByRaw int64 `db:"muted_by"`
RevokedRaw *int64 `db:"revoked_by"`
}
type GroupEvent struct {
ID int64 `db:"id"`
EventID string `db:"event_id"`
Topic string `db:"topic"`
ActorUserID Uid `db:"-"`
EventType string `db:"event_type"`
Payload []byte `db:"payload"`
Status string `db:"status"`
Attempts int `db:"attempts"`
NextRetryAt *time.Time `db:"next_retry_at"`
DeliveredAt *time.Time `db:"delivered_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ActorRaw int64 `db:"actor_user_id"`
}
+56
View File
@@ -0,0 +1,56 @@
package types
import (
"crypto/sha256"
"fmt"
"time"
)
const (
HTTPIdempotencyProcessing = "processing"
HTTPIdempotencyCompleted = "completed"
BusinessEventPending = "pending"
BusinessEventDelivered = "delivered"
)
// HTTPIdempotencyRecord stores the first response for one authenticated write.
type HTTPIdempotencyRecord struct {
UserID Uid
Method string
Route string
Key string
BodyHash string
State string
ResponseStatus int
ResponseBody []byte
CreatedAt time.Time
UpdatedAt time.Time
ExpiresAt time.Time
}
// BusinessEvent is a durable event destined for an online user or topic.
type BusinessEvent struct {
ID int64
EventID string
TargetType string
Target string
ActorUserID Uid
EventType string
Payload []byte
Status string
Attempts int
NextRetryAt *time.Time
DeliveredAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// StableEventID derives an idempotent event identifier from domain identity.
func StableEventID(parts ...any) string {
hash := sha256.New()
for _, part := range parts {
_, _ = fmt.Fprint(hash, part, "\x00")
}
return fmt.Sprintf("evt_%x", hash.Sum(nil)[:24])
}