package mysql import ( "context" "database/sql" "errors" mysqlerr "github.com/go-sql-driver/mysql" "hyapp/pkg/appcode" "hyapp/pkg/xerr" messagedomain "hyapp/services/activity-service/internal/domain/message" ) // SaveInboxMessage inserts one materialized user inbox row and returns an existing row on producer idempotency hits. func (r *Repository) SaveInboxMessage(ctx context.Context, message messagedomain.InboxMessage) (bool, messagedomain.InboxMessage, error) { if r == nil || r.db == nil { return false, messagedomain.InboxMessage{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } app := appcode.FromContext(ctx) message.AppCode = app _, err := r.db.ExecContext(ctx, ` INSERT INTO user_inbox_messages ( app_code, inbox_message_id, user_id, message_type, producer, producer_event_id, producer_event_type, aggregate_type, aggregate_id, template_id, template_version, title, summary, body, icon_url, image_url, action_type, action_param, priority, status, read_at_ms, deleted_at_ms, sent_at_ms, expire_at_ms, metadata_json, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)`, app, message.MessageID, message.UserID, message.MessageType, message.Producer, message.ProducerEventID, message.ProducerEventType, message.AggregateType, message.AggregateID, message.TemplateID, message.TemplateVersion, message.Title, message.Summary, message.Body, message.IconURL, message.ImageURL, message.ActionType, message.ActionParam, message.Priority, message.Status, message.SentAtMS, nullInt64(message.ExpireAtMS), nullString(message.MetadataJSON), message.CreatedAtMS, message.UpdatedAtMS, ) if err == nil { return true, message, nil } if !isMySQLDuplicate(err) { return false, messagedomain.InboxMessage{}, err } existing, exists, findErr := r.GetInboxMessageByProducerEvent(ctx, message.UserID, message.Producer, message.ProducerEventID) if findErr != nil { return false, messagedomain.InboxMessage{}, findErr } if exists { return false, existing, nil } return false, messagedomain.InboxMessage{}, xerr.New(xerr.Conflict, "inbox message id already exists") } // GetInboxMessageByProducerEvent reads the idempotency target for a producer event. func (r *Repository) GetInboxMessageByProducerEvent(ctx context.Context, userID int64, producer string, producerEventID string) (messagedomain.InboxMessage, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, inbox_message_id, user_id, message_type, producer, producer_event_id, producer_event_type, aggregate_type, aggregate_id, template_id, template_version, title, summary, body, icon_url, image_url, action_type, action_param, priority, status, COALESCE(read_at_ms, 0), COALESCE(deleted_at_ms, 0), sent_at_ms, COALESCE(expire_at_ms, 0), COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms FROM user_inbox_messages WHERE app_code = ? AND user_id = ? AND producer = ? AND producer_event_id = ?`, appcode.FromContext(ctx), userID, producer, producerEventID, ) message, err := scanInboxMessage(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return messagedomain.InboxMessage{}, false, nil } return messagedomain.InboxMessage{}, false, err } return message, true, nil } // GetActiveMessageTemplate resolves a server-owned snapshot template. func (r *Repository) GetActiveMessageTemplate(ctx context.Context, templateID string, templateVersion string, messageType string) (messagedomain.MessageTemplate, bool, error) { if r == nil || r.db == nil { return messagedomain.MessageTemplate{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } row := r.db.QueryRowContext(ctx, ` SELECT app_code, template_id, template_version, message_type, locale, title, summary, body, icon_url, image_url, action_type, action_param, status, created_by, created_at_ms, updated_at_ms FROM message_templates WHERE app_code = ? AND template_id = ? AND template_version = ? AND message_type = ? AND status = 'active'`, appcode.FromContext(ctx), templateID, templateVersion, messageType, ) var template messagedomain.MessageTemplate if err := row.Scan(&template.AppCode, &template.TemplateID, &template.TemplateVersion, &template.MessageType, &template.Locale, &template.Title, &template.Summary, &template.Body, &template.IconURL, &template.ImageURL, &template.ActionType, &template.ActionParam, &template.Status, &template.CreatedBy, &template.CreatedAtMS, &template.UpdatedAtMS); err != nil { if errors.Is(err, sql.ErrNoRows) { return messagedomain.MessageTemplate{}, false, nil } return messagedomain.MessageTemplate{}, false, err } return template, true, nil } // CountUnreadInboxMessages counts visible, unexpired and undeleted unread messages by section. func (r *Repository) CountUnreadInboxMessages(ctx context.Context, userID int64, section string, nowMS int64) (int64, error) { if r == nil || r.db == nil { return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } row := r.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM user_inbox_messages WHERE app_code = ? AND user_id = ? AND message_type = ? AND status = 'visible' AND read_at_ms IS NULL AND deleted_at_ms IS NULL AND (expire_at_ms IS NULL OR expire_at_ms > ?)`, appcode.FromContext(ctx), userID, section, nowMS, ) var count int64 if err := row.Scan(&count); err != nil { return 0, err } return count, nil } // ListInboxMessages reads one cursor page of visible messages for the current app and user. func (r *Repository) ListInboxMessages(ctx context.Context, query messagedomain.ListQuery) ([]messagedomain.InboxMessage, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } args := []any{appcode.FromContext(ctx), query.UserID, query.Section, query.NowMS} cursorSQL := "" if query.CursorSentAtMS > 0 || query.CursorMessageID != "" { cursorSQL = " AND (sent_at_ms < ? OR (sent_at_ms = ? AND inbox_message_id < ?))" args = append(args, query.CursorSentAtMS, query.CursorSentAtMS, query.CursorMessageID) } args = append(args, query.Limit) rows, err := r.db.QueryContext(ctx, ` SELECT app_code, inbox_message_id, user_id, message_type, producer, producer_event_id, producer_event_type, aggregate_type, aggregate_id, template_id, template_version, title, summary, body, icon_url, image_url, action_type, action_param, priority, status, COALESCE(read_at_ms, 0), COALESCE(deleted_at_ms, 0), sent_at_ms, COALESCE(expire_at_ms, 0), COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms FROM user_inbox_messages WHERE app_code = ? AND user_id = ? AND message_type = ? AND status = 'visible' AND deleted_at_ms IS NULL AND (expire_at_ms IS NULL OR expire_at_ms > ?)`+cursorSQL+` ORDER BY sent_at_ms DESC, inbox_message_id DESC LIMIT ?`, args...) if err != nil { return nil, err } defer rows.Close() messages := make([]messagedomain.InboxMessage, 0, query.Limit) for rows.Next() { message, scanErr := scanInboxMessage(rows) if scanErr != nil { return nil, scanErr } messages = append(messages, message) } if err := rows.Err(); err != nil { return nil, err } return messages, nil } // MarkInboxMessageRead preserves the first read timestamp and treats repeated reads as success. func (r *Repository) MarkInboxMessageRead(ctx context.Context, userID int64, messageID string, nowMS int64) (messagedomain.InboxMessage, error) { if r == nil || r.db == nil { return messagedomain.InboxMessage{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } message, exists, err := r.getVisibleInboxMessage(ctx, userID, messageID, nowMS) if err != nil { return messagedomain.InboxMessage{}, err } if !exists { return messagedomain.InboxMessage{}, xerr.New(xerr.MessageNotFound, "message not found") } if message.ReadAtMS == 0 { _, err = r.db.ExecContext(ctx, ` UPDATE user_inbox_messages SET read_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND inbox_message_id = ? AND read_at_ms IS NULL`, nowMS, nowMS, appcode.FromContext(ctx), userID, messageID, ) if err != nil { return messagedomain.InboxMessage{}, err } message, _, err = r.getVisibleInboxMessage(ctx, userID, messageID, nowMS) if err != nil { return messagedomain.InboxMessage{}, err } } return message, nil } // MarkInboxSectionRead marks all current visible unread messages in a section. func (r *Repository) MarkInboxSectionRead(ctx context.Context, userID int64, section string, nowMS int64) (int64, error) { if r == nil || r.db == nil { return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } result, err := r.db.ExecContext(ctx, ` UPDATE user_inbox_messages SET read_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND message_type = ? AND status = 'visible' AND read_at_ms IS NULL AND deleted_at_ms IS NULL AND (expire_at_ms IS NULL OR expire_at_ms > ?)`, nowMS, nowMS, appcode.FromContext(ctx), userID, section, nowMS, ) if err != nil { return 0, err } count, err := result.RowsAffected() if err != nil { return 0, err } return count, nil } // DeleteInboxMessage hides one visible message for the current user without deleting audit facts. func (r *Repository) DeleteInboxMessage(ctx context.Context, userID int64, messageID string, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } if _, exists, err := r.getVisibleInboxMessage(ctx, userID, messageID, nowMS); err != nil { return err } else if !exists { return xerr.New(xerr.MessageNotFound, "message not found") } _, err := r.db.ExecContext(ctx, ` UPDATE user_inbox_messages SET deleted_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND inbox_message_id = ? AND deleted_at_ms IS NULL`, nowMS, nowMS, appcode.FromContext(ctx), userID, messageID, ) return err } // SaveFanoutJob records an async fanout command; worker execution is intentionally outside HTTP request path. func (r *Repository) SaveFanoutJob(ctx context.Context, job messagedomain.FanoutJob) (bool, messagedomain.FanoutJob, error) { if r == nil || r.db == nil { return false, messagedomain.FanoutJob{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } app := appcode.FromContext(ctx) job.AppCode = app _, err := r.db.ExecContext(ctx, ` INSERT INTO message_fanout_jobs ( app_code, job_id, command_id, message_type, target_scope, target_filter_json, template_snapshot_json, status, attempt_count, cursor_user_id, total_count, success_count, failure_count, batch_size, next_run_at_ms, locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, app, job.JobID, job.CommandID, job.MessageType, job.TargetScope, job.TargetFilterJSON, job.TemplateSnapshotJSON, job.Status, job.AttemptCount, job.CursorUserID, job.TotalCount, job.SuccessCount, job.FailureCount, job.BatchSize, job.NextRunAtMS, job.LockedBy, job.LockedUntilMS, job.ErrorMessage, job.CreatedBy, job.CreatedAtMS, job.UpdatedAtMS, ) if err == nil { return true, job, nil } if !isMySQLDuplicate(err) { return false, messagedomain.FanoutJob{}, err } existing, exists, findErr := r.GetFanoutJobByCommand(ctx, job.CommandID) if findErr != nil { return false, messagedomain.FanoutJob{}, findErr } if exists { return false, existing, nil } return false, messagedomain.FanoutJob{}, xerr.New(xerr.Conflict, "fanout job id already exists") } // GetFanoutJobByCommand reads the idempotency target for admin command retries. func (r *Repository) GetFanoutJobByCommand(ctx context.Context, commandID string) (messagedomain.FanoutJob, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, job_id, command_id, message_type, target_scope, CAST(target_filter_json AS CHAR), CAST(template_snapshot_json AS CHAR), status, attempt_count, cursor_user_id, total_count, success_count, failure_count, batch_size, next_run_at_ms, locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms FROM message_fanout_jobs WHERE app_code = ? AND command_id = ?`, appcode.FromContext(ctx), commandID, ) var job messagedomain.FanoutJob if err := scanFanoutJob(row, &job); err != nil { if errors.Is(err, sql.ErrNoRows) { return messagedomain.FanoutJob{}, false, nil } return messagedomain.FanoutJob{}, false, err } return job, true, nil } // ClaimFanoutJob commits ownership before processing so another worker can take over after lock expiry. func (r *Repository) ClaimFanoutJob(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, maxRetry int) (messagedomain.FanoutJob, bool, error) { if r == nil || r.db == nil { return messagedomain.FanoutJob{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return messagedomain.FanoutJob{}, false, err } defer tx.Rollback() row := tx.QueryRowContext(ctx, ` SELECT app_code, job_id, command_id, message_type, target_scope, CAST(target_filter_json AS CHAR), CAST(template_snapshot_json AS CHAR), status, attempt_count, cursor_user_id, total_count, success_count, failure_count, batch_size, next_run_at_ms, locked_by, locked_until_ms, error_message, created_by, created_at_ms, updated_at_ms FROM message_fanout_jobs WHERE app_code = ? AND next_run_at_ms <= ? AND attempt_count < ? AND (status IN (?, ?) OR (status = ? AND locked_until_ms < ?)) ORDER BY next_run_at_ms ASC, updated_at_ms ASC, job_id ASC LIMIT 1 FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), nowMS, maxRetry, messagedomain.FanoutStatusPending, messagedomain.FanoutStatusRetrying, messagedomain.FanoutStatusRunning, nowMS, ) var job messagedomain.FanoutJob if err := scanFanoutJob(row, &job); err != nil { if errors.Is(err, sql.ErrNoRows) { if commitErr := tx.Commit(); commitErr != nil { return messagedomain.FanoutJob{}, false, commitErr } return messagedomain.FanoutJob{}, false, nil } return messagedomain.FanoutJob{}, false, err } _, err = tx.ExecContext(ctx, ` UPDATE message_fanout_jobs SET status = ?, locked_by = ?, locked_until_ms = ?, attempt_count = attempt_count + 1, updated_at_ms = ? WHERE app_code = ? AND job_id = ?`, messagedomain.FanoutStatusRunning, workerID, lockUntilMS, nowMS, job.AppCode, job.JobID, ) if err != nil { return messagedomain.FanoutJob{}, false, err } if err := tx.Commit(); err != nil { return messagedomain.FanoutJob{}, false, err } job.Status = messagedomain.FanoutStatusRunning job.LockedBy = workerID job.LockedUntilMS = lockUntilMS job.AttemptCount++ job.UpdatedAtMS = nowMS return job, true, nil } // FinishFanoutJobBatch commits one processed cursor page and clears the durable worker lock. func (r *Repository) FinishFanoutJobBatch(ctx context.Context, jobID string, workerID string, cursorUserID int64, totalDelta int64, successDelta int64, failureDelta int64, done bool, nowMS int64, errorMessage string) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } status := messagedomain.FanoutStatusPending if done { status = messagedomain.FanoutStatusComplete } result, err := r.db.ExecContext(ctx, ` UPDATE message_fanout_jobs SET status = ?, cursor_user_id = ?, total_count = total_count + ?, success_count = success_count + ?, failure_count = failure_count + ?, next_run_at_ms = ?, locked_by = '', locked_until_ms = 0, error_message = ?, updated_at_ms = ? WHERE app_code = ? AND job_id = ? AND status = ? AND locked_by = ?`, status, cursorUserID, totalDelta, successDelta, failureDelta, nowMS, truncateFanoutError(errorMessage), nowMS, appcode.FromContext(ctx), jobID, messagedomain.FanoutStatusRunning, workerID, ) if err != nil { return err } affected, err := result.RowsAffected() if err != nil { return err } if affected == 0 { return xerr.New(xerr.Conflict, "fanout job lock is lost") } return nil } // FailFanoutJob releases a failed claim; exhausted jobs become failed instead of retrying forever. func (r *Repository) FailFanoutJob(ctx context.Context, jobID string, workerID string, nowMS int64, nextRunAtMS int64, maxRetry int, errorMessage string) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } result, err := r.db.ExecContext(ctx, ` UPDATE message_fanout_jobs SET status = CASE WHEN attempt_count >= ? THEN ? ELSE ? END, next_run_at_ms = CASE WHEN attempt_count >= ? THEN 0 ELSE ? END, locked_by = '', locked_until_ms = 0, error_message = ?, updated_at_ms = ? WHERE app_code = ? AND job_id = ? AND status = ? AND locked_by = ?`, maxRetry, messagedomain.FanoutStatusFailed, messagedomain.FanoutStatusRetrying, maxRetry, nextRunAtMS, truncateFanoutError(errorMessage), nowMS, appcode.FromContext(ctx), jobID, messagedomain.FanoutStatusRunning, workerID, ) if err != nil { return err } affected, err := result.RowsAffected() if err != nil { return err } if affected == 0 { return xerr.New(xerr.Conflict, "fanout job lock is lost") } return nil } func (r *Repository) getVisibleInboxMessage(ctx context.Context, userID int64, messageID string, nowMS int64) (messagedomain.InboxMessage, bool, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, inbox_message_id, user_id, message_type, producer, producer_event_id, producer_event_type, aggregate_type, aggregate_id, template_id, template_version, title, summary, body, icon_url, image_url, action_type, action_param, priority, status, COALESCE(read_at_ms, 0), COALESCE(deleted_at_ms, 0), sent_at_ms, COALESCE(expire_at_ms, 0), COALESCE(CAST(metadata_json AS CHAR), ''), created_at_ms, updated_at_ms FROM user_inbox_messages WHERE app_code = ? AND user_id = ? AND inbox_message_id = ? AND status = 'visible' AND deleted_at_ms IS NULL AND (expire_at_ms IS NULL OR expire_at_ms > ?)`, appcode.FromContext(ctx), userID, messageID, nowMS, ) message, err := scanInboxMessage(row) if err != nil { if errors.Is(err, sql.ErrNoRows) { return messagedomain.InboxMessage{}, false, nil } return messagedomain.InboxMessage{}, false, err } return message, true, nil } type inboxScanner interface { Scan(dest ...any) error } func scanFanoutJob(scanner inboxScanner, job *messagedomain.FanoutJob) error { return scanner.Scan( &job.AppCode, &job.JobID, &job.CommandID, &job.MessageType, &job.TargetScope, &job.TargetFilterJSON, &job.TemplateSnapshotJSON, &job.Status, &job.AttemptCount, &job.CursorUserID, &job.TotalCount, &job.SuccessCount, &job.FailureCount, &job.BatchSize, &job.NextRunAtMS, &job.LockedBy, &job.LockedUntilMS, &job.ErrorMessage, &job.CreatedBy, &job.CreatedAtMS, &job.UpdatedAtMS, ) } func scanInboxMessage(scanner inboxScanner) (messagedomain.InboxMessage, error) { var message messagedomain.InboxMessage err := scanner.Scan( &message.AppCode, &message.MessageID, &message.UserID, &message.MessageType, &message.Producer, &message.ProducerEventID, &message.ProducerEventType, &message.AggregateType, &message.AggregateID, &message.TemplateID, &message.TemplateVersion, &message.Title, &message.Summary, &message.Body, &message.IconURL, &message.ImageURL, &message.ActionType, &message.ActionParam, &message.Priority, &message.Status, &message.ReadAtMS, &message.DeletedAtMS, &message.SentAtMS, &message.ExpireAtMS, &message.MetadataJSON, &message.CreatedAtMS, &message.UpdatedAtMS, ) return message, err } func nullInt64(value int64) sql.Null[int64] { return sql.Null[int64]{V: value, Valid: value > 0} } func nullString(value string) sql.Null[string] { return sql.Null[string]{V: value, Valid: value != ""} } func truncateFanoutError(message string) string { if len(message) <= 512 { return message } return message[:512] } func isMySQLDuplicate(err error) bool { var mysqlErr *mysqlerr.MySQLError return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 }