485 lines
16 KiB
Go

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
}
}