2026-05-12 09:53:20 +08:00

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
}