//go:build mysql // +build mysql package mysql import ( "context" "database/sql" "encoding/json" "errors" "strings" "time" "github.com/jmoiron/sqlx" t "github.com/tinode/chat/server/store/types" ) func (a *adapter) TenantLoginAuditCreate(id t.TenantID, event *t.LoginAuditEvent) error { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || event == nil || event.UserID.IsZero() { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantLoginAuditCreate(id, event) } metadata, err := json.Marshal(event.Metadata) if err != nil { return err } _, err = a.db.Exec(`INSERT INTO im_user_login_logs (tenant_id,user_id,username,account_type,account_hash,account_mask,login_type,event_type,session_id,device_id,platform,app_version,ip,region,user_agent,risk_level,request_id,login_at,logout_at,metadata) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, int64(id), decodeUID(event.UserID), event.Username, event.AccountType, event.AccountHash, event.AccountMask, event.LoginType, event.EventType, event.SessionID, event.DeviceID, event.Platform, event.AppVersion, event.IP, event.Region, event.UserAgent, event.RiskLevel, event.RequestID, event.LoginAt, event.LogoutAt, nullableJSON(metadata)) return err } func (a *adapter) TenantContentModerationLogCreate(id t.TenantID, event *t.ContentModerationLog) error { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || event == nil || strings.TrimSpace(event.RequestID) == "" { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantContentModerationLogCreate(id, event) } labels, err := json.Marshal(event.RiskLabels) if err != nil { return err } var actorID any if !event.ActorID.IsZero() { actorID = decodeUID(event.ActorID) } _, err = a.db.Exec(`INSERT INTO im_content_moderation_logs (request_id,scene,topic,seqid,actor_id,provider,provider_request_id,risk_level,risk_labels,action,content_hash,latency_ms,error_code,created_at) VALUES(?,?,?,?,?,?,?,?,CAST(? AS JSON),?,?,?,?,?)`, event.RequestID, event.Scene, event.Topic, event.SeqID, actorID, event.Provider, event.ProviderRequestID, event.RiskLevel, string(labels), event.Action, event.ContentHash, event.LatencyMS, event.ErrorCode, event.CreatedAt) return err } func (a *adapter) TenantLoginFailureCreate(id t.TenantID, event *t.LoginFailureEvent) error { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || event == nil { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantLoginFailureCreate(id, event) } metadata, err := json.Marshal(event.Metadata) if err != nil { return err } var userID any if !event.UserID.IsZero() { userID = decodeUID(event.UserID) } _, err = a.db.Exec(`INSERT INTO im_login_failure_logs (tenant_id,user_id,account_type,account_hash,account_mask,failure_type,reason,detail,ip,region,device_id,platform,app_version,user_agent,risk_level,risk_action,request_id,attempt_at,metadata) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, int64(id), userID, event.AccountType, event.AccountHash, event.AccountMask, event.FailureType, event.Reason, event.Detail, event.IP, event.Region, event.DeviceID, event.Platform, event.AppVersion, event.UserAgent, event.RiskLevel, event.RiskAction, event.RequestID, event.AttemptAt, nullableJSON(metadata)) return err } func (a *adapter) TenantLoginAuditClose(id t.TenantID, sessionID string, at time.Time) error { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || strings.TrimSpace(sessionID) == "" { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantLoginAuditClose(id, sessionID, at) } return retryMySQLDeadlock(func() error { _, execErr := a.db.Exec("UPDATE im_user_login_logs SET logout_at=? WHERE tenant_id=? AND session_id=? AND logout_at IS NULL", at, int64(id), sessionID) return execErr }, time.Sleep) } func retryMySQLDeadlock(operation func() error, sleep func(time.Duration)) error { err := operation() for attempt := 1; isRetryableMySQLError(err) && attempt < 4; attempt++ { sleep(time.Duration(attempt) * 25 * time.Millisecond) err = operation() } return err } func (a *adapter) TenantMessageAdminApply(id t.TenantID, action *t.MessageAdminAction) error { tenantAdp, err := a.routedTenantAdapter(id) if err != nil || action == nil || action.Topic == "" || action.SeqID <= 0 || (action.Action != "hide" && action.Action != "delete") { return t.ErrMalformed } if tenantAdp != a { return tenantAdp.TenantMessageAdminApply(id, action) } ctx, cancel := a.getContext() if cancel != nil { defer cancel() } tx, err := a.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() var existing int64 if err = tx.GetContext(ctx, &existing, "SELECT COUNT(*) FROM im_message_admin_actions WHERE command_id=?", action.CommandID); err != nil { return err } if existing > 0 { return tx.Commit() } sender, beforeStatus, messageTable, err := lockAdminMessage(ctx, tx, id, action.ConversationType, action.Topic, action.SeqID) if err != nil { return err } before, _ := json.Marshal(map[string]any{"status": beforeStatus, "sender_user_id": sender}) afterStatus := "hidden" if action.Action == "delete" { afterStatus = "deleted" } if action.ConversationType == "group" { _, err = tx.ExecContext(ctx, "UPDATE "+messageTable+" SET status=?,deleted_at=?,updated_at=? WHERE topic=? AND seqid=?", afterStatus, action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID) } else { _, err = tx.ExecContext(ctx, "UPDATE "+messageTable+" SET deletedat=?,updatedat=? WHERE topic=? AND seqid=?", action.ProcessedAt, action.ProcessedAt, action.Topic, action.SeqID) } if err != nil { return err } after, _ := json.Marshal(map[string]any{"status": afterStatus, "sender_user_id": sender}) _, err = tx.ExecContext(ctx, `INSERT INTO im_message_admin_actions (action_id,command_id,conversation_type,topic,seqid,sender_user_id,action,action_status,reason,actor_admin_id,request_id,before_snapshot,after_snapshot,processed_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,CAST(? AS JSON),CAST(? AS JSON),?)`, action.ActionID, action.CommandID, action.ConversationType, action.Topic, action.SeqID, sender, action.Action, "success", action.Reason, action.ActorAdminID, action.RequestID, string(before), string(after), action.ProcessedAt) if err != nil { return err } return tx.Commit() } func lockAdminMessage(ctx context.Context, tx *sqlx.Tx, tenantID t.TenantID, conversationType, topic string, seqID int64) (int64, string, string, error) { if conversationType == "group" { tables, err := groupMessageTablesForSeq(tx, ctx, tenantID, topic, seqID) if err != nil { return 0, "", "", err } var row struct { Sender int64 `db:"from_user"` Status string `db:"status"` } for _, table := range tables { err = tx.GetContext(ctx, &row, "SELECT from_user,status FROM "+table+" WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID) if err == nil { return row.Sender, row.Status, table, nil } if !errors.Is(err, sql.ErrNoRows) { return 0, "", "", err } } return 0, "", "", t.ErrNotFound } var row struct { Sender int64 `db:"from"` DeletedAt sql.NullTime `db:"deletedat"` RecalledAt sql.NullTime `db:"recalled_at"` } messageTable := tenantMessagesTable(tenantID) err := tx.GetContext(ctx, &row, "SELECT `from`,deletedat,recalled_at FROM "+messageTable+" WHERE topic=? AND seqid=? FOR UPDATE", topic, seqID) if errors.Is(err, sql.ErrNoRows) { return 0, "", "", t.ErrNotFound } status := "normal" if row.RecalledAt.Valid { status = "recalled" } if row.DeletedAt.Valid { status = "deleted" } return row.Sender, status, messageTable, err } func nullableJSON(raw []byte) any { if len(raw) == 0 || string(raw) == "null" { return nil } return string(raw) }