398 lines
13 KiB
Go
398 lines
13 KiB
Go
package mysql
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/walletmq"
|
||
"hyapp/pkg/xerr"
|
||
)
|
||
|
||
const (
|
||
giftWallProjectionName = "user_gift_wall"
|
||
walletGiftDebitedEvent = "WalletGiftDebited"
|
||
|
||
// projection 状态机只描述“某个 outbox 事件是否已经投影进礼物墙”。
|
||
// 这里不复用 wallet_outbox.status,避免展示读模型反向污染账务事件分发语义。
|
||
projectionStatusProcessing = "processing"
|
||
projectionStatusFailed = "failed"
|
||
projectionStatusDone = "done"
|
||
projectionStatusDead = "dead"
|
||
projectionMaxRetries = 10
|
||
)
|
||
|
||
type giftWallProjectionCandidate struct {
|
||
AppCode string
|
||
EventID string
|
||
CreatedAtMS int64
|
||
}
|
||
|
||
type giftWallProjectionEvent struct {
|
||
AppCode string
|
||
EventID string
|
||
TransactionID string
|
||
CommandID string
|
||
PayloadJSON string
|
||
CreatedAtMS int64
|
||
}
|
||
|
||
// ProjectPendingGiftWallEvents 把 WalletGiftDebited outbox 事件异步投影到 user_gift_wall。
|
||
// 扣费主事务只写账务事实和 outbox,礼物墙由 worker 后补,因此查询天然允许短暂延迟。
|
||
// 单条事件的聚合写入和 done 标记在同一事务内提交;服务重启时未提交事务回滚,已 done 的事件不会重复聚合。
|
||
func (r *Repository) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) {
|
||
if r == nil || r.db == nil {
|
||
return 0, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
workerID = strings.TrimSpace(workerID)
|
||
if workerID == "" {
|
||
return 0, xerr.New(xerr.InvalidArgument, "worker_id is required")
|
||
}
|
||
if limit <= 0 {
|
||
limit = 20
|
||
}
|
||
if limit > 100 {
|
||
limit = 100
|
||
}
|
||
if lockTTL <= 0 {
|
||
lockTTL = 30 * time.Second
|
||
}
|
||
|
||
candidates, err := r.listGiftWallProjectionCandidates(ctx, limit)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
processed := 0
|
||
for _, candidate := range candidates {
|
||
ok, err := r.projectGiftWallEvent(ctx, candidate, workerID, lockTTL)
|
||
if err != nil {
|
||
return processed, err
|
||
}
|
||
if ok {
|
||
processed++
|
||
}
|
||
}
|
||
return processed, nil
|
||
}
|
||
|
||
// ProjectGiftWallMessage projects one wallet_outbox MQ fact into user_gift_wall.
|
||
// Runtime projection consumes MQ and does not scan wallet_outbox for candidates.
|
||
func (r *Repository) ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) {
|
||
if r == nil || r.db == nil {
|
||
return false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||
}
|
||
if message.EventType != walletGiftDebitedEvent {
|
||
return false, nil
|
||
}
|
||
workerID = strings.TrimSpace(workerID)
|
||
if workerID == "" {
|
||
return false, xerr.New(xerr.InvalidArgument, "worker_id is required")
|
||
}
|
||
if lockTTL <= 0 {
|
||
lockTTL = 30 * time.Second
|
||
}
|
||
nowMs := time.Now().UnixMilli()
|
||
lockUntilMS := time.Now().Add(lockTTL).UnixMilli()
|
||
event := giftWallProjectionEvent{
|
||
AppCode: appcode.Normalize(message.AppCode),
|
||
EventID: strings.TrimSpace(message.EventID),
|
||
TransactionID: strings.TrimSpace(message.TransactionID),
|
||
CommandID: strings.TrimSpace(message.CommandID),
|
||
PayloadJSON: strings.TrimSpace(message.PayloadJSON),
|
||
CreatedAtMS: message.OccurredAtMS,
|
||
}
|
||
if event.PayloadJSON == "" {
|
||
event.PayloadJSON = "{}"
|
||
}
|
||
ctx = appcode.WithContext(ctx, event.AppCode)
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
|
||
}
|
||
|
||
func (r *Repository) listGiftWallProjectionCandidates(ctx context.Context, limit int) ([]giftWallProjectionCandidate, error) {
|
||
nowMs := time.Now().UnixMilli()
|
||
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_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.event_type = ?
|
||
AND pe.event_id IS NULL
|
||
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
|
||
LIMIT ?`,
|
||
giftWallProjectionName,
|
||
appCode,
|
||
walletGiftDebitedEvent,
|
||
limit,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
candidates, err := scanGiftWallProjectionCandidates(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.event_type = ?
|
||
ORDER BY wo.created_at_ms ASC, wo.event_id ASC
|
||
LIMIT ?`,
|
||
giftWallProjectionName,
|
||
appCode,
|
||
projectionStatusFailed,
|
||
projectionStatusProcessing,
|
||
nowMs,
|
||
walletGiftDebitedEvent,
|
||
limit,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
retryCandidates, err := scanGiftWallProjectionCandidates(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 scanGiftWallProjectionCandidates(rows *sql.Rows, limit int) ([]giftWallProjectionCandidate, error) {
|
||
defer rows.Close()
|
||
candidates := make([]giftWallProjectionCandidate, 0, limit)
|
||
for rows.Next() {
|
||
var candidate giftWallProjectionCandidate
|
||
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) projectGiftWallEvent(ctx context.Context, candidate giftWallProjectionCandidate, workerID string, lockTTL time.Duration) (bool, error) {
|
||
nowMs := time.Now().UnixMilli()
|
||
lockUntilMS := time.Now().Add(lockTTL).UnixMilli()
|
||
ctx = appcode.WithContext(ctx, candidate.AppCode)
|
||
|
||
tx, err := r.db.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
event, err := r.lockGiftWallOutboxEvent(ctx, tx, candidate)
|
||
if err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
// 候选扫描和事务锁定之间可能被清理或并发处理;这种竞争不应让 worker 失败。
|
||
return false, nil
|
||
}
|
||
return false, err
|
||
}
|
||
return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
|
||
}
|
||
|
||
func (r *Repository) projectGiftWallLockedEvent(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, workerID string, lockUntilMS int64, nowMs int64) (bool, error) {
|
||
claimed, err := r.claimProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
|
||
if err != nil || !claimed {
|
||
return false, err
|
||
}
|
||
|
||
var metadata giftMetadata
|
||
if err := json.Unmarshal([]byte(event.PayloadJSON), &metadata); err != nil {
|
||
// 坏 payload 是单条事件的数据问题,记录失败后跳过本轮,避免一个坏事件卡死整个队列。
|
||
if markErr := r.markProjectionFailedInTx(ctx, tx, event, "invalid gift projection payload: "+err.Error(), nowMs); markErr != nil {
|
||
return false, markErr
|
||
}
|
||
return true, tx.Commit()
|
||
}
|
||
if err := validateGiftWallMetadata(metadata); err != nil {
|
||
if markErr := r.markProjectionFailedInTx(ctx, tx, event, err.Error(), nowMs); markErr != nil {
|
||
return false, markErr
|
||
}
|
||
return true, tx.Commit()
|
||
}
|
||
if err := r.upsertUserGiftWall(ctx, tx, metadata, event.CreatedAtMS); err != nil {
|
||
// 聚合写失败时先释放当前事务锁,再把 projection 标记为 failed;下轮按 retry_count 继续重试。
|
||
_ = tx.Rollback()
|
||
if markErr := r.markProjectionFailed(ctx, event, err, nowMs); markErr != nil {
|
||
return false, markErr
|
||
}
|
||
return false, err
|
||
}
|
||
if err := r.markProjectionDoneInTx(ctx, tx, event, nowMs); err != nil {
|
||
return false, err
|
||
}
|
||
return true, tx.Commit()
|
||
}
|
||
|
||
func (r *Repository) lockGiftWallOutboxEvent(ctx context.Context, tx *sql.Tx, candidate giftWallProjectionCandidate) (giftWallProjectionEvent, error) {
|
||
var event giftWallProjectionEvent
|
||
err := tx.QueryRowContext(ctx, `
|
||
SELECT app_code, event_id, transaction_id, command_id, COALESCE(CAST(payload AS CHAR), '{}'), created_at_ms
|
||
FROM wallet_outbox
|
||
WHERE app_code = ? AND event_id = ? AND event_type = ?
|
||
FOR UPDATE`,
|
||
appcode.Normalize(candidate.AppCode),
|
||
candidate.EventID,
|
||
walletGiftDebitedEvent,
|
||
).Scan(&event.AppCode, &event.EventID, &event.TransactionID, &event.CommandID, &event.PayloadJSON, &event.CreatedAtMS)
|
||
return event, err
|
||
}
|
||
|
||
func (r *Repository) claimProjectionEvent(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, workerID string, lockUntilMS int64, nowMs int64) (bool, error) {
|
||
// 新事件通过 INSERT 获取处理权;并发 worker 命中同一事件时只有一个实例会插入成功。
|
||
_, 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, ?, ?)`,
|
||
giftWallProjectionName,
|
||
event.AppCode,
|
||
event.EventID,
|
||
projectionStatusProcessing,
|
||
workerID,
|
||
lockUntilMS,
|
||
nowMs,
|
||
nowMs,
|
||
)
|
||
if err == nil {
|
||
return true, nil
|
||
}
|
||
if !isMySQLDuplicateError(err) {
|
||
return false, err
|
||
}
|
||
|
||
// 已存在的 failed/过期 processing 事件允许重新认领;done/dead 永远不被重复投影。
|
||
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,
|
||
giftWallProjectionName,
|
||
event.AppCode,
|
||
event.EventID,
|
||
projectionStatusFailed,
|
||
projectionStatusProcessing,
|
||
nowMs,
|
||
)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
affected, err := result.RowsAffected()
|
||
return affected > 0, err
|
||
}
|
||
|
||
func (r *Repository) markProjectionDoneInTx(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, 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,
|
||
giftWallProjectionName,
|
||
event.AppCode,
|
||
event.EventID,
|
||
)
|
||
return err
|
||
}
|
||
|
||
func (r *Repository) markProjectionFailedInTx(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, reason string, nowMs int64) error {
|
||
reason = trimProjectionError(reason)
|
||
// 失败次数达到上限后转 dead,保留 last_error 给后台排查,不再无限重试污染 MySQL。
|
||
_, 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,
|
||
reason,
|
||
nowMs,
|
||
giftWallProjectionName,
|
||
event.AppCode,
|
||
event.EventID,
|
||
)
|
||
return err
|
||
}
|
||
|
||
func (r *Repository) markProjectionFailed(ctx context.Context, event giftWallProjectionEvent, cause error, nowMs int64) error {
|
||
reason := trimProjectionError(cause.Error())
|
||
_, 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)`,
|
||
giftWallProjectionName,
|
||
event.AppCode,
|
||
event.EventID,
|
||
projectionStatusFailed,
|
||
reason,
|
||
nowMs,
|
||
nowMs,
|
||
projectionMaxRetries,
|
||
projectionStatusDead,
|
||
projectionStatusFailed,
|
||
)
|
||
return err
|
||
}
|
||
|
||
func validateGiftWallMetadata(metadata giftMetadata) error {
|
||
// 礼物墙以收礼人、礼物 ID 和数量为最小可投影事实;缺任何一个都不能安全聚合。
|
||
if metadata.TargetUserID <= 0 || strings.TrimSpace(metadata.GiftID) == "" || metadata.GiftCount <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "gift projection metadata is incomplete")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func trimProjectionError(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if len(value) > 1024 {
|
||
return value[:1024]
|
||
}
|
||
return value
|
||
}
|