393 lines
16 KiB
Go
393 lines
16 KiB
Go
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
|
||
}
|