306 lines
12 KiB
Go
306 lines
12 KiB
Go
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]
|
||
}
|