2026-07-18 15:32:58 +08:00

306 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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