package mysql import ( "context" "database/sql" "encoding/json" "errors" "sort" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/walletmq" "hyapp/pkg/xerr" resourcedomain "hyapp/services/wallet-service/internal/domain/resource" ) const ( badgeDisplayProjectionName = "activity_badge_display" walletResourceGrantedEvent = "ResourceGranted" walletResourceGroupGrantedEvent = "ResourceGroupGranted" grantSourceVIPPurchase = "vip_purchase" ) type badgeGrantProjectionCandidate struct { AppCode string EventID string CreatedAtMS int64 } type badgeGrantOutboxPayload struct { GrantID string `json:"grant_id"` Source string `json:"source"` } type badgeGrantProjectionPayload struct { GrantID string `json:"grant_id"` GrantSource string `json:"grant_source"` CommandID string `json:"command_id"` EventType string `json:"event_type"` Items []resourcedomain.BadgeGrantItem `json:"items"` } // ClaimPendingBadgeGrantEvents claims wallet resource-grant outbox events that should be relayed to activity badge slots. func (r *Repository) ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } workerID = strings.TrimSpace(workerID) if workerID == "" { return nil, xerr.New(xerr.InvalidArgument, "worker_id is required") } if batchSize <= 0 { batchSize = 50 } if batchSize > 100 { batchSize = 100 } if lockTTL <= 0 { lockTTL = 30 * time.Second } candidates, err := r.listBadgeGrantProjectionCandidates(ctx, nowMS, batchSize) if err != nil { return nil, err } events := make([]resourcedomain.BadgeGrantOutbox, 0, len(candidates)) for _, candidate := range candidates { event, claimed, err := r.claimBadgeGrantEvent(ctx, candidate, workerID, nowMS, lockTTL) if err != nil { return events, err } if claimed { events = append(events, event) } } return events, nil } // ClaimBadgeGrantMessage claims one wallet_outbox MQ fact for badge display projection. // Runtime projection consumes MQ and does not scan wallet_outbox for candidates. func (r *Repository) ClaimBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) { if r == nil || r.db == nil { return resourcedomain.BadgeGrantOutbox{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } if !isBadgeGrantWalletMessage(message) { return resourcedomain.BadgeGrantOutbox{}, false, nil } workerID = strings.TrimSpace(workerID) if workerID == "" { return resourcedomain.BadgeGrantOutbox{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required") } if nowMS <= 0 { nowMS = time.Now().UnixMilli() } if lockTTL <= 0 { lockTTL = 30 * time.Second } event := resourcedomain.BadgeGrantOutbox{ AppCode: appcode.Normalize(message.AppCode), EventID: strings.TrimSpace(message.EventID), EventType: strings.TrimSpace(message.EventType), CommandID: strings.TrimSpace(message.CommandID), UserID: message.UserID, PayloadJSON: strings.TrimSpace(message.PayloadJSON), CreatedAtMS: message.OccurredAtMS, UpdatedAtMS: message.OccurredAtMS, } if event.PayloadJSON == "" { event.PayloadJSON = "{}" } ctx = appcode.WithContext(ctx, event.AppCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.BadgeGrantOutbox{}, false, err } defer func() { _ = tx.Rollback() }() return r.claimBadgeGrantEventInTx(ctx, tx, event, workerID, nowMS, lockTTL) } func (r *Repository) listBadgeGrantProjectionCandidates(ctx context.Context, nowMS int64, limit int) ([]badgeGrantProjectionCandidate, error) { appCode := appcode.FromContext(ctx) rows, err := r.db.QueryContext(ctx, ` SELECT wo.app_code, wo.event_id, wo.created_at_ms FROM wallet_outbox wo FORCE INDEX (idx_wallet_outbox_asset_event_created) LEFT JOIN wallet_projection_events pe ON pe.projection_name = ? AND pe.app_code = wo.app_code AND pe.event_id = wo.event_id WHERE wo.app_code = ? AND wo.asset_type = ? AND wo.event_type IN (?, ?) AND pe.event_id IS NULL ORDER BY wo.created_at_ms ASC, wo.event_id ASC LIMIT ?`, badgeDisplayProjectionName, appCode, resourceOutboxAsset, walletResourceGrantedEvent, walletResourceGroupGrantedEvent, limit, ) if err != nil { return nil, err } candidates, err := scanBadgeGrantProjectionCandidates(rows, limit) if err != nil { return nil, err } rows, err = r.db.QueryContext(ctx, ` SELECT wo.app_code, wo.event_id, wo.created_at_ms FROM wallet_projection_events pe JOIN wallet_outbox wo ON wo.app_code = pe.app_code AND wo.event_id = pe.event_id WHERE pe.projection_name = ? AND pe.app_code = ? AND pe.status IN (?, ?) AND pe.locked_until_ms <= ? AND wo.asset_type = ? AND wo.event_type IN (?, ?) ORDER BY wo.created_at_ms ASC, wo.event_id ASC LIMIT ?`, badgeDisplayProjectionName, appCode, projectionStatusFailed, projectionStatusProcessing, nowMS, resourceOutboxAsset, walletResourceGrantedEvent, walletResourceGroupGrantedEvent, limit, ) if err != nil { return nil, err } retryCandidates, err := scanBadgeGrantProjectionCandidates(rows, limit) if err != nil { return nil, err } candidates = append(candidates, retryCandidates...) sort.SliceStable(candidates, func(i, j int) bool { if candidates[i].CreatedAtMS == candidates[j].CreatedAtMS { return candidates[i].EventID < candidates[j].EventID } return candidates[i].CreatedAtMS < candidates[j].CreatedAtMS }) if len(candidates) > limit { candidates = candidates[:limit] } return candidates, nil } func isBadgeGrantWalletMessage(message walletmq.WalletOutboxMessage) bool { if strings.TrimSpace(message.AssetType) != resourceOutboxAsset { return false } switch message.EventType { case walletResourceGrantedEvent, walletResourceGroupGrantedEvent: return true default: return false } } func scanBadgeGrantProjectionCandidates(rows *sql.Rows, limit int) ([]badgeGrantProjectionCandidate, error) { defer rows.Close() candidates := make([]badgeGrantProjectionCandidate, 0, limit) for rows.Next() { var candidate badgeGrantProjectionCandidate if err := rows.Scan(&candidate.AppCode, &candidate.EventID, &candidate.CreatedAtMS); err != nil { return nil, err } candidates = append(candidates, candidate) } return candidates, rows.Err() } func (r *Repository) claimBadgeGrantEvent(ctx context.Context, candidate badgeGrantProjectionCandidate, workerID string, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) { ctx = appcode.WithContext(ctx, candidate.AppCode) tx, err := r.db.BeginTx(ctx, nil) if err != nil { return resourcedomain.BadgeGrantOutbox{}, false, err } defer func() { _ = tx.Rollback() }() event, err := r.lockBadgeGrantOutboxEvent(ctx, tx, candidate) if err != nil { if errors.Is(err, sql.ErrNoRows) { return resourcedomain.BadgeGrantOutbox{}, false, nil } return resourcedomain.BadgeGrantOutbox{}, false, err } return r.claimBadgeGrantEventInTx(ctx, tx, event, workerID, nowMS, lockTTL) } func (r *Repository) claimBadgeGrantEventInTx(ctx context.Context, tx *sql.Tx, event resourcedomain.BadgeGrantOutbox, workerID string, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) { lockUntilMS := time.UnixMilli(nowMS).Add(lockTTL).UnixMilli() claimed, err := r.claimBadgeProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMS) if err != nil || !claimed { return resourcedomain.BadgeGrantOutbox{}, false, err } var outboxPayload badgeGrantOutboxPayload if err := json.Unmarshal([]byte(event.PayloadJSON), &outboxPayload); err != nil || strings.TrimSpace(outboxPayload.GrantID) == "" { reason := "invalid resource grant outbox payload" if err != nil { reason += ": " + err.Error() } if markErr := r.markBadgeProjectionFailedInTx(ctx, tx, event, reason, nowMS); markErr != nil { return resourcedomain.BadgeGrantOutbox{}, false, markErr } return resourcedomain.BadgeGrantOutbox{}, false, tx.Commit() } grant, err := r.getResourceGrantTx(ctx, tx, strings.TrimSpace(outboxPayload.GrantID)) if err != nil { if errors.Is(err, sql.ErrNoRows) { if markErr := r.markBadgeProjectionFailedInTx(ctx, tx, event, "resource grant not found", nowMS); markErr != nil { return resourcedomain.BadgeGrantOutbox{}, false, markErr } return resourcedomain.BadgeGrantOutbox{}, false, tx.Commit() } return resourcedomain.BadgeGrantOutbox{}, false, err } event.GrantID = grant.GrantID event.GrantSource = grant.GrantSource event.UserID = grant.TargetUserID if shouldSkipBadgeGrantSource(grant.GrantSource) { if err := r.markBadgeProjectionDoneInTx(ctx, tx, event, nowMS); err != nil { return resourcedomain.BadgeGrantOutbox{}, false, err } return resourcedomain.BadgeGrantOutbox{}, false, tx.Commit() } items, err := badgeGrantItemsFromResourceGrant(grant) if err != nil { if markErr := r.markBadgeProjectionFailedInTx(ctx, tx, event, err.Error(), nowMS); markErr != nil { return resourcedomain.BadgeGrantOutbox{}, false, markErr } return resourcedomain.BadgeGrantOutbox{}, false, tx.Commit() } if len(items) == 0 { if err := r.markBadgeProjectionDoneInTx(ctx, tx, event, nowMS); err != nil { return resourcedomain.BadgeGrantOutbox{}, false, err } return resourcedomain.BadgeGrantOutbox{}, false, tx.Commit() } payloadJSON, err := json.Marshal(badgeGrantProjectionPayload{ GrantID: grant.GrantID, GrantSource: grant.GrantSource, CommandID: event.CommandID, EventType: event.EventType, Items: items, }) if err != nil { return resourcedomain.BadgeGrantOutbox{}, false, err } event.Items = items event.PayloadJSON = string(payloadJSON) if err := tx.Commit(); err != nil { return resourcedomain.BadgeGrantOutbox{}, false, err } return event, true, nil } func (r *Repository) lockBadgeGrantOutboxEvent(ctx context.Context, tx *sql.Tx, candidate badgeGrantProjectionCandidate) (resourcedomain.BadgeGrantOutbox, error) { var event resourcedomain.BadgeGrantOutbox err := tx.QueryRowContext(ctx, ` SELECT app_code, event_id, event_type, command_id, user_id, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms, updated_at_ms FROM wallet_outbox WHERE app_code = ? AND event_id = ? AND asset_type = ? AND event_type IN (?, ?) FOR UPDATE`, appcode.Normalize(candidate.AppCode), candidate.EventID, resourceOutboxAsset, walletResourceGrantedEvent, walletResourceGroupGrantedEvent, ).Scan(&event.AppCode, &event.EventID, &event.EventType, &event.CommandID, &event.UserID, &event.PayloadJSON, &event.CreatedAtMS, &event.UpdatedAtMS) return event, err } func (r *Repository) claimBadgeProjectionEvent(ctx context.Context, tx *sql.Tx, event resourcedomain.BadgeGrantOutbox, workerID string, lockUntilMS int64, nowMS int64) (bool, error) { _, err := tx.ExecContext(ctx, ` INSERT INTO wallet_projection_events ( projection_name, app_code, event_id, status, retry_count, locked_by, locked_until_ms, last_error, processed_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 0, ?, ?, '', 0, ?, ?)`, badgeDisplayProjectionName, event.AppCode, event.EventID, projectionStatusProcessing, workerID, lockUntilMS, nowMS, nowMS, ) if err == nil { return true, nil } if !isMySQLDuplicateError(err) { return false, err } result, err := tx.ExecContext(ctx, ` UPDATE wallet_projection_events SET status = ?, locked_by = ?, locked_until_ms = ?, updated_at_ms = ? WHERE projection_name = ? AND app_code = ? AND event_id = ? AND status IN (?, ?) AND locked_until_ms <= ?`, projectionStatusProcessing, workerID, lockUntilMS, nowMS, badgeDisplayProjectionName, event.AppCode, event.EventID, projectionStatusFailed, projectionStatusProcessing, nowMS, ) if err != nil { return false, err } affected, err := result.RowsAffected() return affected > 0, err } // MarkBadgeGrantEventDelivered marks a resource-grant event as successfully relayed to activity-service. func (r *Repository) MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } _, err := r.db.ExecContext(ctx, ` UPDATE wallet_projection_events SET status = ?, locked_by = '', locked_until_ms = 0, last_error = '', processed_at_ms = ?, updated_at_ms = ? WHERE projection_name = ? AND app_code = ? AND event_id = ?`, projectionStatusDone, nowMS, nowMS, badgeDisplayProjectionName, event.AppCode, event.EventID, ) return err } // MarkBadgeGrantEventFailed releases the projection lock and records a retryable activity relay failure. func (r *Repository) MarkBadgeGrantEventFailed(ctx context.Context, event resourcedomain.BadgeGrantOutbox, failureReason string, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } reason := trimProjectionError(failureReason) _, err := r.db.ExecContext(ctx, ` INSERT INTO wallet_projection_events ( projection_name, app_code, event_id, status, retry_count, locked_by, locked_until_ms, last_error, processed_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, 1, '', 0, ?, 0, ?, ?) ON DUPLICATE KEY UPDATE status = IF(retry_count + 1 >= ?, ?, ?), retry_count = retry_count + 1, locked_by = '', locked_until_ms = 0, last_error = VALUES(last_error), updated_at_ms = VALUES(updated_at_ms)`, badgeDisplayProjectionName, event.AppCode, event.EventID, projectionStatusFailed, reason, nowMS, nowMS, projectionMaxRetries, projectionStatusDead, projectionStatusFailed, ) return err } func (r *Repository) markBadgeProjectionDoneInTx(ctx context.Context, tx *sql.Tx, event resourcedomain.BadgeGrantOutbox, nowMS int64) error { _, err := tx.ExecContext(ctx, ` UPDATE wallet_projection_events SET status = ?, locked_by = '', locked_until_ms = 0, last_error = '', processed_at_ms = ?, updated_at_ms = ? WHERE projection_name = ? AND app_code = ? AND event_id = ?`, projectionStatusDone, nowMS, nowMS, badgeDisplayProjectionName, event.AppCode, event.EventID, ) return err } func (r *Repository) markBadgeProjectionFailedInTx(ctx context.Context, tx *sql.Tx, event resourcedomain.BadgeGrantOutbox, reason string, nowMS int64) error { _, err := tx.ExecContext(ctx, ` UPDATE wallet_projection_events SET status = IF(retry_count + 1 >= ?, ?, ?), retry_count = retry_count + 1, locked_by = '', locked_until_ms = 0, last_error = ?, updated_at_ms = ? WHERE projection_name = ? AND app_code = ? AND event_id = ?`, projectionMaxRetries, projectionStatusDead, projectionStatusFailed, trimProjectionError(reason), nowMS, badgeDisplayProjectionName, event.AppCode, event.EventID, ) return err } func badgeGrantItemsFromResourceGrant(grant resourcedomain.ResourceGrant) ([]resourcedomain.BadgeGrantItem, error) { items := make([]resourcedomain.BadgeGrantItem, 0, len(grant.Items)) for _, grantItem := range grant.Items { if grantItem.ResultType != resourcedomain.ResultEntitlement { continue } var resource resourcedomain.Resource if err := json.Unmarshal([]byte(grantItem.ResourceSnapshotJSON), &resource); err != nil { return nil, xerr.New(xerr.InvalidArgument, "invalid badge resource snapshot: "+err.Error()) } if resourcedomain.NormalizeResourceType(resource.ResourceType) != resourcedomain.TypeBadge { continue } items = append(items, resourcedomain.BadgeGrantItem{ ResourceID: resource.ResourceID, ResourceCode: resource.ResourceCode, ResourceType: resource.ResourceType, Name: resource.Name, EntitlementID: grantItem.EntitlementID, ResourceSnapshotJSON: grantItem.ResourceSnapshotJSON, MetadataJSON: resource.MetadataJSON, }) } return items, nil } func shouldSkipBadgeGrantSource(source string) bool { switch strings.ToLower(strings.TrimSpace(source)) { case resourcedomain.GrantSourceAchievement, resourcedomain.GrantSourceGrowthLevel, grantSourceVIPPurchase: return true default: return false } }