567 lines
20 KiB
Go
567 lines
20 KiB
Go
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
|
|
}
|