package yumigiftchallenge import ( "context" "errors" "fmt" "net/http" "strings" "time" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) func (s *Service) createDeliveryItemsTx(tx *gorm.DB, ownerType string, ownerID, activityID, userID int64, resourceGroupID *int64) error { if resourceGroupID == nil || *resourceGroupID <= 0 { return NewAppError(http.StatusConflict, "reward_group_missing", "reward group is missing") } var count int64 if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}). Where("owner_type = ? AND owner_id = ?", ownerType, ownerID).Count(&count).Error; err != nil { return err } if count > 0 { return nil } var snapshots []model.YumiGiftChallengeRewardSnapshot if err := tx.Where("activity_id = ? AND resource_group_id = ?", activityID, *resourceGroupID). Order("sort_order ASC, reward_config_id ASC").Find(&snapshots).Error; err != nil { return err } if len(snapshots) == 0 { return NewAppError(http.StatusConflict, "reward_snapshot_missing", "frozen reward snapshot is missing") } now := time.Now() items := make([]model.YumiGiftChallengeDeliveryItem, 0, len(snapshots)) for _, snapshot := range snapshots { id, err := utils.NextID() if err != nil { return err } items = append(items, model.YumiGiftChallengeDeliveryItem{ ID: id, OwnerType: ownerType, OwnerID: ownerID, ActivityID: activityID, UserID: userID, RewardConfigID: snapshot.RewardConfigID, ResourceGroupID: snapshot.ResourceGroupID, RewardType: snapshot.RewardType, DetailType: snapshot.DetailType, Content: snapshot.Content, Quantity: snapshot.Quantity, SortOrder: snapshot.SortOrder, Remark: snapshot.Remark, DeliveryStatus: DeliveryPending, CreateTime: now, UpdateTime: now, }) } // owner_type+owner_id+reward_config_id 唯一键是逐奖励项幂等边界;若并发已经物化,整个事务回滚后调用方重读即可。 if err := tx.Create(&items).Error; err != nil && !isDuplicateKey(err) { return err } return nil } func (s *Service) deliverOwnerItems(ctx context.Context, ownerType string, ownerID int64) error { if err := ctx.Err(); err != nil { return err } if err := s.expireOwnerProcessing(ctx, ownerType, ownerID); err != nil { return err } for { // 请求或 worker 取消后必须在下一次 claim 前停住,未抢占的奖励项保持 PENDING, // 不能拿已取消的 context 批量调用 Java 并把整期奖励污染成 UNKNOWN。 if err := ctx.Err(); err != nil { return err } item, err := s.claimNextDeliveryItem(ctx, ownerType, ownerID) if err != nil { return err } if item == nil { break } request := integration.SendFrozenActivityRewardRequest{ // 外层 trackId 是当前 delivery item 主键,保证不同用户和不同奖励项互不冲突。 TrackID: item.ID, AcceptUserID: item.UserID, Origin: "ACTIVITY_REWARD", SysOrigin: defaultSysOrigin, Prizes: []integration.FrozenActivityReward{{ // Java PrizeDescribe.trackId 沿用资源组(商品)ID;真正的发奖幂等号是外层 delivery item ID。 TrackID: item.ResourceGroupID, Type: item.RewardType, DetailType: item.DetailType, Content: item.Content, Quantity: item.Quantity, Remark: item.Remark, }}, } deliveryErr := s.gateway.SendFrozenActivityReward(ctx, request) // 外部调用一旦发出,即使原 context 已取消也必须用独立短 context 落下 SUCCESS/UNKNOWN; // 状态落库失败时停止后续发送,避免失去可核账的幂等边界。 stateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) finishErr := s.finishDeliveryAttempt(stateCtx, *item, deliveryErr) cancel() if finishErr != nil { return finishErr } if deliveryErr != nil && (errors.Is(deliveryErr, context.Canceled) || errors.Is(deliveryErr, context.DeadlineExceeded) || ctx.Err() != nil) { if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr } return deliveryErr } } stateCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) err := s.refreshOwnerAndPeriod(stateCtx, ownerType, ownerID) cancel() return err } func (s *Service) lockOwner(tx *gorm.DB, ownerType string, ownerID int64) error { if ownerType == OwnerTask { var row model.YumiGiftChallengeUserTaskDaily return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", ownerID).First(&row).Error } if ownerType == OwnerSettlement { var row model.YumiGiftChallengeSettlement return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", ownerID).First(&row).Error } return NewAppError(http.StatusBadRequest, "invalid_owner_type", "invalid delivery owner type") } func (s *Service) expireOwnerProcessing(ctx context.Context, ownerType string, ownerID int64) error { leaseSeconds := s.cfg.YumiGiftChallenge.DeliveryProcessingLeaseSeconds if leaseSeconds < 30 { leaseSeconds = 300 } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 所有状态聚合统一 owner→item 锁序,避免补发、核账和正常发送互相死锁。 if err := s.lockOwner(tx, ownerType, ownerID); err != nil { return err } return tx.Model(&model.YumiGiftChallengeDeliveryItem{}). Where("owner_type = ? AND owner_id = ? AND delivery_status = ? AND update_time < ?", ownerType, ownerID, DeliveryProcessing, time.Now().Add(-time.Duration(leaseSeconds)*time.Second)). Updates(map[string]any{ "delivery_status": DeliveryUnknown, "failure_reason": "DELIVERY_RESULT_UNKNOWN: processing lease expired; reconcile downstream before resolving", "update_time": time.Now(), }).Error }) } func (s *Service) claimNextDeliveryItem(ctx context.Context, ownerType string, ownerID int64) (*model.YumiGiftChallengeDeliveryItem, error) { var claimed *model.YumiGiftChallengeDeliveryItem err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.lockOwner(tx, ownerType, ownerID); err != nil { return err } var item model.YumiGiftChallengeDeliveryItem err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}). Where("owner_type = ? AND owner_id = ? AND delivery_status = ?", ownerType, ownerID, DeliveryPending). Order("sort_order ASC, id ASC").First(&item).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil } if err != nil { return err } now := time.Now() result := tx.Model(&model.YumiGiftChallengeDeliveryItem{}). Where("id = ? AND delivery_status = ?", item.ID, DeliveryPending). Updates(map[string]any{"delivery_status": DeliveryProcessing, "retry_count": gorm.Expr("retry_count + 1"), "update_time": now}) if result.Error != nil || result.RowsAffected != 1 { return result.Error } item.DeliveryStatus = DeliveryProcessing claimed = &item return nil }) return claimed, err } func (s *Service) finishDeliveryAttempt(ctx context.Context, candidate model.YumiGiftChallengeDeliveryItem, deliveryErr error) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.lockOwner(tx, candidate.OwnerType, candidate.OwnerID); err != nil { return err } var item model.YumiGiftChallengeDeliveryItem if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", candidate.ID).First(&item).Error; err != nil { return err } if item.DeliveryStatus != DeliveryProcessing { return nil } now := time.Now() updates := map[string]any{"update_time": now} if deliveryErr == nil { updates["delivery_status"], updates["failure_reason"], updates["deliver_time"] = DeliverySuccess, "", now } else { // 外部调用超时、断连或返回异常时无法证明未到账,统一 UNKNOWN;自动流程绝不盲目重发。 updates["delivery_status"] = DeliveryUnknown updates["failure_reason"] = truncate(fmt.Sprintf("DELIVERY_RESULT_UNKNOWN: %v", deliveryErr), 500) } if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("id = ?", item.ID).Updates(updates).Error; err != nil { return err } return s.finishOwnerStatusLocked(tx, candidate.OwnerType, candidate.OwnerID) }) } func (s *Service) finishOwnerStatusLocked(tx *gorm.DB, ownerType string, ownerID int64) error { var total, unfinished, unknown int64 base := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("owner_type = ? AND owner_id = ?", ownerType, ownerID) if err := base.Count(&total).Error; err != nil { return err } if total == 0 { return NewAppError(http.StatusConflict, "delivery_items_missing", "delivery items are missing") } if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}). Where("owner_type = ? AND owner_id = ? AND delivery_status <> ?", ownerType, ownerID, DeliverySuccess).Count(&unfinished).Error; err != nil { return err } if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}). Where("owner_type = ? AND owner_id = ? AND delivery_status = ?", ownerType, ownerID, DeliveryUnknown).Count(&unknown).Error; err != nil { return err } status, reason := DeliveryProcessing, "" updates := map[string]any{"update_time": time.Now()} if unfinished == 0 { status, updates["deliver_time"] = DeliverySuccess, time.Now() } else if unknown > 0 { status, reason = DeliveryUnknown, "MANUAL_RECONCILIATION_REQUIRED: resolve UNKNOWN items after downstream audit" } updates["delivery_status"], updates["failure_reason"] = status, reason if ownerType == OwnerTask { return tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).Where("id = ?", ownerID).Updates(updates).Error } return tx.Model(&model.YumiGiftChallengeSettlement{}).Where("id = ?", ownerID).Updates(updates).Error } func (s *Service) refreshOwnerAndPeriod(ctx context.Context, ownerType string, ownerID int64) error { var activityID int64 var period, periodKey string err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.lockOwner(tx, ownerType, ownerID); err != nil { return err } if err := s.finishOwnerStatusLocked(tx, ownerType, ownerID); err != nil { return err } if ownerType == OwnerSettlement { var row model.YumiGiftChallengeSettlement if err := tx.Where("id = ?", ownerID).First(&row).Error; err != nil { return err } activityID, period, periodKey = row.ActivityID, row.PeriodType, row.PeriodKey } return nil }) if err != nil || ownerType != OwnerSettlement { return err } return s.refreshPeriodStatus(ctx, activityID, period, periodKey) } // ResolveUnknownDeliveryItem 只有运营核账后才能把 UNKNOWN 标成到账或重置为 PENDING 补发。 func (s *Service) ResolveUnknownDeliveryItem(ctx context.Context, itemID int64, delivered bool) error { var candidate model.YumiGiftChallengeDeliveryItem if err := s.db.WithContext(ctx).Where("id = ?", itemID).First(&candidate).Error; errors.Is(err, gorm.ErrRecordNotFound) { return NewAppError(http.StatusNotFound, "delivery_item_not_found", "delivery item not found") } else if err != nil { return err } err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.lockOwner(tx, candidate.OwnerType, candidate.OwnerID); err != nil { return err } var item model.YumiGiftChallengeDeliveryItem if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", itemID).First(&item).Error; err != nil { return err } if item.DeliveryStatus != DeliveryUnknown { return NewAppError(http.StatusConflict, "delivery_item_not_unknown", "only UNKNOWN item can be reconciled") } status := DeliveryPending updates := map[string]any{"delivery_status": status, "failure_reason": "", "update_time": time.Now()} if delivered { status = DeliverySuccess updates["delivery_status"], updates["deliver_time"] = status, time.Now() } if err := tx.Model(&model.YumiGiftChallengeDeliveryItem{}).Where("id = ?", itemID).Updates(updates).Error; err != nil { return err } return s.finishOwnerStatusLocked(tx, candidate.OwnerType, candidate.OwnerID) }) if err != nil { return err } if !delivered { if err := s.deliverOwnerItems(ctx, candidate.OwnerType, candidate.OwnerID); err != nil { return err } } return s.refreshOwnerAndPeriod(ctx, candidate.OwnerType, candidate.OwnerID) } func truncate(value string, max int) string { value = strings.TrimSpace(value) if len(value) <= max { return value } return value[:max] }