485 lines
16 KiB
Go
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
|
|
}
|
|
}
|