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

393 lines
16 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 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再复用普通 retrydelivered=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
}