package gameking import ( "context" "errors" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "gorm.io/gorm" ) const deliveryProcessingLease = 10 * time.Minute type frozenDeliveryContext struct { SysOrigin string BusinessNo string RewardItemsJSON string RewardItems []RewardItemView } // dispatchDeliveryItem 原子领取一个 PENDING/人工确认后的 FAILED 奖励组并调用 Java。 // 一旦 HTTP 调用被尝试,任何错误都进入 UNKNOWN;只有调用前校验失败才是可重试 FAILED。 func (s *Service) dispatchDeliveryItem(ctx context.Context, itemID int64, retry bool) error { // 事务已提交后发奖不能继承 H5/Admin/cron 连接取消,否则会在真正调用 Java 前留下 // PROCESSING 并被误判 UNKNOWN。脱离上游取消但保留硬超时,避免无界后台工作。 dispatchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second) defer cancel() ctx = dispatchCtx var item model.YumiGameKingDeliveryItem claimed := false if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "delivery_item_not_found", "delivery item was not found") } return err } switch item.DeliveryStatus { case DeliverySuccess: return nil case DeliveryUnknown: return NewAppError(http.StatusConflict, "delivery_unknown_requires_resolution", "UNKNOWN delivery must be reconciled before retry") case DeliveryProcessing: return NewAppError(http.StatusConflict, "delivery_processing", "delivery is already processing") case DeliveryPending: if retry { return NewAppError(http.StatusConflict, "delivery_not_retryable", "only FAILED delivery can be retried") } case DeliveryFailed: if !retry { return NewAppError(http.StatusConflict, "delivery_not_pending", "FAILED delivery requires an explicit retry") } default: return NewAppError(http.StatusConflict, "delivery_status_invalid", "delivery status is invalid") } now := time.Now() updates := map[string]any{"delivery_status": DeliveryProcessing, "failure_reason": "", "update_time": now} if retry { updates["retry_count"] = gorm.Expr("retry_count + 1") item.RetryCount++ } if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(updates).Error; err != nil { return err } if err := updateDeliveryParentTx(tx, item, DeliveryProcessing, "", nil, retry); err != nil { return err } item.DeliveryStatus = DeliveryProcessing item.UpdateTime = now claimed = true return nil }); err != nil { return err } if !claimed { return nil } frozen, err := s.loadFrozenDeliveryContext(ctx, item) if err != nil { _ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error()) return err } current, err := s.loadRewardGroupSnapshot(ctx, frozen.SysOrigin, item.ResourceGroupID) if err != nil { // 奖励组查询发生在真正发奖之前,因此网络失败、下架、跨租户或不存在都能明确 // 判定“未调用发奖”,记录 FAILED,待配置恢复后由运营显式 retry。 _ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error()) return err } if current.ItemsJSON != frozen.RewardItemsJSON { err = NewAppError(http.StatusConflict, "reward_group_snapshot_changed", "reward group content changed after activity enable; restore it before retry") _ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error()) return err } if s.gateway == nil { err = NewAppError(http.StatusServiceUnavailable, "reward_gateway_unavailable", "reward gateway is unavailable") _ = s.finishDeliveryItem(ctx, item.ID, DeliveryFailed, err.Error()) return err } err = s.gateway.SendActivityReward(ctx, integration.SendActivityRewardRequest{ TrackID: item.ID, Origin: rewardOriginGameKing, SysOrigin: frozen.SysOrigin, SourceGroupID: item.ResourceGroupID, AcceptUserID: item.UserID, }) if err != nil { // HTTP 已经发出后无法证明 Java 是否落账;包括 4xx/409 在内全部 UNKNOWN, // 禁止自动或普通 retry,必须用 trackId/businessNo 核账后人工 resolve。 _ = s.finishDeliveryItem(ctx, item.ID, DeliveryUnknown, err.Error()) return err } return s.finishDeliveryItem(ctx, item.ID, DeliverySuccess, "") } func (s *Service) finishDeliveryItem(ctx context.Context, itemID int64, status, reason string) error { // Java 调用和前置检查共用 20 秒操作超时,但最终状态必须使用独立短租约落库; // 否则操作刚好超时时,已取消的 ctx 会让 FAILED/UNKNOWN/SUCCESS 都留在 PROCESSING。 finalizeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() return s.db.WithContext(finalizeCtx).Transaction(func(tx *gorm.DB) error { var item model.YumiGameKingDeliveryItem if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil { return err } if item.DeliveryStatus != DeliveryProcessing { return nil } now := time.Now() var deliverTime *time.Time if status == DeliverySuccess { deliverTime = &now } if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(map[string]any{ "delivery_status": status, "failure_reason": truncateFailure(reason), "deliver_time": deliverTime, "update_time": now, }).Error; err != nil { return err } if err := updateDeliveryParentTx(tx, item, status, reason, deliverTime, false); err != nil { return err } if item.OwnerType == OwnerSettlement { return refreshSettlementStatusTx(tx, item.ActivityID) } return nil }) } func updateDeliveryParentTx(tx *gorm.DB, item model.YumiGameKingDeliveryItem, status, reason string, deliverTime *time.Time, incrementRetry bool) error { updates := map[string]any{ "delivery_status": status, "failure_reason": truncateFailure(reason), "deliver_time": deliverTime, "update_time": time.Now(), } if incrementRetry { updates["retry_count"] = gorm.Expr("retry_count + 1") } switch item.OwnerType { case OwnerDraw: return tx.Model(&model.YumiGameKingDrawRecord{}).Where("id = ?", item.OwnerID).Updates(updates).Error case OwnerSettlement: return tx.Model(&model.YumiGameKingSettlementRecord{}).Where("id = ?", item.OwnerID).Updates(updates).Error default: return NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid") } } func refreshSettlementStatusTx(tx *gorm.DB, activityID int64) error { var total, succeeded, unfinished, failed int64 base := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ?", activityID) if err := base.Count(&total).Error; err != nil { return err } if total == 0 { return nil } if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status = ?", activityID, DeliverySuccess).Count(&succeeded).Error; err != nil { return err } if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryPending, DeliveryProcessing}).Count(&unfinished).Error; err != nil { return err } if err := tx.Model(&model.YumiGameKingSettlementRecord{}).Where("activity_id = ? AND delivery_status IN ?", activityID, []string{DeliveryFailed, DeliveryUnknown}).Count(&failed).Error; err != nil { return err } status := SettlementProcessing if succeeded == total { status = SettlementCompleted } else if failed > 0 { status = SettlementPartialFailed } else if unfinished == 0 { status = SettlementPartialFailed } return tx.Model(&model.YumiGameKingActivity{}).Where("id = ?", activityID). Updates(map[string]any{"settlement_status": status, "update_time": time.Now()}).Error } func (s *Service) loadFrozenDeliveryContext(ctx context.Context, item model.YumiGameKingDeliveryItem) (frozenDeliveryContext, error) { var activity model.YumiGameKingActivity if err := s.db.WithContext(ctx).Where("id = ?", item.ActivityID).First(&activity).Error; err != nil { return frozenDeliveryContext{}, err } result := frozenDeliveryContext{SysOrigin: activity.SysOrigin} switch item.OwnerType { case OwnerDraw: var record model.YumiGameKingDrawRecord if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil { return result, err } var prize model.YumiGameKingPrize if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.PrizeID, item.ActivityID).First(&prize).Error; err != nil { return result, err } if prize.ResourceGroupID != item.ResourceGroupID { return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "draw reward group does not match frozen prize") } result.BusinessNo = fmt.Sprintf("YUMI_GAME_KING_DRAW:%d", record.ID) result.RewardItemsJSON = prize.RewardItemsJSON result.RewardItems = rewardItems(prize.RewardItemsJSON) case OwnerSettlement: var record model.YumiGameKingSettlementRecord if err := s.db.WithContext(ctx).Where("id = ?", item.OwnerID).First(&record).Error; err != nil { return result, err } var rankReward model.YumiGameKingRankReward if err := s.db.WithContext(ctx).Where("id = ? AND activity_id = ?", record.RankRewardID, item.ActivityID).First(&rankReward).Error; err != nil { return result, err } if rankReward.ResourceGroupID != item.ResourceGroupID { return result, NewAppError(http.StatusConflict, "delivery_snapshot_mismatch", "settlement reward group does not match frozen rank tier") } result.BusinessNo = record.BusinessNo result.RewardItemsJSON = rankReward.RewardItemsJSON result.RewardItems = rewardItems(rankReward.RewardItemsJSON) default: return result, NewAppError(http.StatusConflict, "delivery_owner_invalid", "delivery owner type is invalid") } if strings.TrimSpace(result.RewardItemsJSON) == "" || len(result.RewardItems) == 0 { return result, NewAppError(http.StatusConflict, "delivery_snapshot_empty", "frozen reward snapshot is empty") } return result, nil } func (s *Service) deliveryItems(ctx context.Context, ownerType string, ownerID int64) ([]DeliveryItemView, error) { var rows []model.YumiGameKingDeliveryItem if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", ownerType, ownerID).Order("id ASC").Find(&rows).Error; err != nil { return nil, err } result := make([]DeliveryItemView, 0, len(rows)) for _, row := range rows { frozen, err := s.loadFrozenDeliveryContext(ctx, row) if err != nil { return nil, err } result = append(result, DeliveryItemView{ ID: row.ID, TrackID: row.ID, OwnerType: row.OwnerType, OwnerID: row.OwnerID, BusinessNo: frozen.BusinessNo, UserID: row.UserID, ResourceGroupID: row.ResourceGroupID, DeliveryStatus: row.DeliveryStatus, RetryCount: row.RetryCount, FailureReason: row.FailureReason, DeliverTime: unixMilliPtr(row.DeliverTime), UpdateTime: unixMilli(row.UpdateTime), RewardItems: frozen.RewardItems, }) } return result, nil } func (s *Service) RetryDraw(ctx context.Context, recordID int64) error { var record model.YumiGameKingDrawRecord if err := s.db.WithContext(ctx).Where("id = ?", recordID).First(&record).Error; err != nil { return err } if record.DeliveryStatus != DeliveryFailed { return NewAppError(http.StatusConflict, "draw_not_retryable", "only FAILED draw rewards can be retried") } var item model.YumiGameKingDeliveryItem if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerDraw, record.ID).First(&item).Error; err != nil { return err } return s.dispatchDeliveryItem(ctx, item.ID, true) } func (s *Service) RetrySettlement(ctx context.Context, recordID int64) error { var record model.YumiGameKingSettlementRecord if err := s.db.WithContext(ctx).Where("id = ?", recordID).First(&record).Error; err != nil { return err } if record.DeliveryStatus != DeliveryFailed { return NewAppError(http.StatusConflict, "settlement_not_retryable", "only FAILED settlement rewards can be retried") } var item model.YumiGameKingDeliveryItem if err := s.db.WithContext(ctx).Where("owner_type = ? AND owner_id = ?", OwnerSettlement, record.ID).First(&item).Error; err != nil { return err } return s.dispatchDeliveryItem(ctx, item.ID, true) } // ResolveUnknownDeliveryItem 只能由运营核账后调用。delivered=false 是明确未到账授权, // 服务先把 UNKNOWN 原子转为 FAILED,再复用普通 retry;delivered=true 只落成功,不再发送。 func (s *Service) ResolveUnknownDeliveryItem(ctx context.Context, itemID int64, delivered bool) error { var item model.YumiGameKingDeliveryItem if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := withWriteLock(tx).Where("id = ?", itemID).First(&item).Error; err != nil { return err } if item.DeliveryStatus != DeliveryUnknown { return NewAppError(http.StatusConflict, "delivery_not_unknown", "only UNKNOWN delivery can be resolved") } now := time.Now() status := DeliveryFailed var deliveredAt *time.Time failureReason := "operator confirmed not delivered" if delivered { status = DeliverySuccess deliveredAt = &now failureReason = "" } if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(map[string]any{ "delivery_status": status, "failure_reason": failureReason, "deliver_time": deliveredAt, "update_time": now, }).Error; err != nil { return err } if err := updateDeliveryParentTx(tx, item, status, failureReason, deliveredAt, false); err != nil { return err } if item.OwnerType == OwnerSettlement { return refreshSettlementStatusTx(tx, item.ActivityID) } return nil }); err != nil { return err } if delivered { return nil } return s.dispatchDeliveryItem(ctx, item.ID, true) } // RecoverPendingDeliveries 供 settle-due 恢复“事务已提交但进程尚未调用下游”的 PENDING。 // 过期 PROCESSING 由调用方在同一批次先行处理,避免这里重复扫描;FAILED/UNKNOWN 永不自动重试。 func (s *Service) RecoverPendingDeliveries(ctx context.Context, limit int) (int, error) { if limit <= 0 || limit > 100 { limit = 100 } var rows []model.YumiGameKingDeliveryItem if err := s.db.WithContext(ctx).Where("delivery_status = ?", DeliveryPending).Order("update_time ASC, id ASC").Limit(limit).Find(&rows).Error; err != nil { return 0, err } processed := 0 for _, row := range rows { // 外部调用错误已经可靠落为 FAILED/UNKNOWN;恢复扫描继续处理其他 PENDING, // 只把查询/事务级错误作为扫描失败返回。 _ = s.dispatchDeliveryItem(ctx, row.ID, false) processed++ } return processed, nil } func (s *Service) markStaleProcessingUnknown(ctx context.Context, limit int) error { var rows []model.YumiGameKingDeliveryItem if err := s.db.WithContext(ctx).Where("delivery_status = ? AND update_time < ?", DeliveryProcessing, time.Now().Add(-deliveryProcessingLease)). Order("update_time ASC").Limit(limit).Find(&rows).Error; err != nil { return err } for _, candidate := range rows { if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var item model.YumiGameKingDeliveryItem if err := withWriteLock(tx).Where("id = ?", candidate.ID).First(&item).Error; err != nil { return err } if item.DeliveryStatus != DeliveryProcessing || !item.UpdateTime.Before(time.Now().Add(-deliveryProcessingLease)) { return nil } now := time.Now() reason := "delivery processing lease expired; downstream result requires reconciliation" if err := tx.Model(&model.YumiGameKingDeliveryItem{}).Where("id = ?", item.ID).Updates(map[string]any{ "delivery_status": DeliveryUnknown, "failure_reason": reason, "update_time": now, }).Error; err != nil { return err } if err := updateDeliveryParentTx(tx, item, DeliveryUnknown, reason, nil, false); err != nil { return err } if item.OwnerType == OwnerSettlement { return refreshSettlementStatusTx(tx, item.ActivityID) } return nil }); err != nil { return err } } return nil }