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

242 lines
9.6 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/model"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Enter 在活动时区创建当天任务快照,并幂等完成进入页面任务。
func (s *Service) Enter(ctx context.Context, user AuthUser, req EnterRequest) (*UserStateResponse, error) {
origin, err := s.requireYumiOrigin(user.SysOrigin)
if err != nil {
return nil, err
}
if user.UserID <= 0 {
return nil, NewAppError(http.StatusUnauthorized, "invalid_user", "authenticated user is required")
}
activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true)
if err != nil {
return nil, err
}
if activityStatus(*activity, time.Now()) != "ONGOING" {
return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing")
}
location, err := resolveLocation(activity.Timezone)
if err != nil {
return nil, err
}
statDate := dateKey(time.Now(), location)
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var header model.YumiGiftChallengePeriodSettlement
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
"activity_id = ? AND period_type = ? AND period_key = ?", activity.ID, PeriodDaily, statDate,
).First(&header).Error; err != nil {
return err
}
if header.Status != StatusNotStarted {
return NewAppError(http.StatusConflict, "daily_period_closed", "daily task period is already frozen")
}
if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil {
return err
}
now := time.Now()
return tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).
Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_type = ? AND completed_time IS NULL",
activity.ID, statDate, user.UserID, TaskEnterPage).
Updates(map[string]any{"progress_value": model.Decimal24_2("1.00"), "completed_time": now, "update_time": now}).Error
})
if err != nil {
return nil, err
}
return s.buildUserState(ctx, *activity, statDate, user.UserID)
}
func (s *Service) ensureTaskSnapshotsTx(tx *gorm.DB, activity model.YumiGiftChallengeActivity, statDate string, userID int64) error {
var configs []model.YumiGiftChallengeTaskConfig
if err := tx.Where("activity_id = ? AND enabled = ?", activity.ID, true).
Order("sort_order ASC, id ASC").Find(&configs).Error; err != nil {
return err
}
if len(configs) != 3 {
return NewAppError(http.StatusConflict, "invalid_activity_tasks", "enabled activity must contain exactly three tasks")
}
now := time.Now()
for _, config := range configs {
id, err := utils.NextID()
if err != nil {
return err
}
row := model.YumiGiftChallengeUserTaskDaily{
ID: id, ActivityID: activity.ID, StatDate: statDate, UserID: userID,
TaskConfigID: config.ID, TaskCode: config.TaskCode, TaskType: config.TaskType,
TaskTitle: config.TaskTitle, SortOrder: config.SortOrder, TargetValue: config.TargetValue,
ResourceGroupID: config.ResourceGroupID,
BusinessNo: fmt.Sprintf("YUMI_GIFT_CHALLENGE:TASK:%d:%s:%d:%s", activity.ID, statDate, userID, config.TaskCode),
DeliveryStatus: DeliveryNotClaimed, CreateTime: now, UpdateTime: now,
}
// 活动、统计日、用户和 taskCode 唯一键同时承接页面重进和送礼并发,只有第一份任务配置成为当天快照。
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return err
}
}
return nil
}
func mustLocation(value string) *time.Location {
location, err := time.LoadLocation(strings.TrimSpace(value))
if err != nil {
return time.UTC
}
return location
}
// ClaimTask 先在本地事务抢占任务 parent 并物化 delivery item再逐项调用外部发奖。
func (s *Service) ClaimTask(ctx context.Context, user AuthUser, taskCode string, req ClaimRequest) (*UserStateResponse, error) {
origin, err := s.requireYumiOrigin(user.SysOrigin)
if err != nil {
return nil, err
}
taskCode = strings.TrimSpace(taskCode)
if !taskCodePattern.MatchString(taskCode) {
return nil, NewAppError(http.StatusBadRequest, "invalid_task_code", "invalid taskCode")
}
requestID := strings.TrimSpace(req.RequestID)
if requestID == "" || len(requestID) > 128 {
return nil, NewAppError(http.StatusBadRequest, "invalid_request_id", "requestId is required and max length is 128")
}
activity, err := s.selectActivity(ctx, origin, req.ActivityID.Int64(), true)
if err != nil {
return nil, err
}
if activityStatus(*activity, time.Now()) != "ONGOING" {
return nil, NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing")
}
location, err := resolveLocation(activity.Timezone)
if err != nil {
return nil, err
}
statDate := dateKey(time.Now(), location)
var ownerID int64
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.ensureTaskSnapshotsTx(tx, *activity, statDate, user.UserID); err != nil {
return err
}
var task model.YumiGiftChallengeUserTaskDaily
queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_code = ?", activity.ID, statDate, user.UserID, taskCode).
First(&task).Error
if errors.Is(queryErr, gorm.ErrRecordNotFound) {
return NewAppError(http.StatusNotFound, "task_not_found", "task not found")
}
if queryErr != nil {
return queryErr
}
ownerID = task.ID
if task.CompletedTime == nil || task.ProgressValue.Compare(task.TargetValue) < 0 {
return NewAppError(http.StatusConflict, "task_not_completed", "task is not completed")
}
switch task.DeliveryStatus {
case DeliverySuccess:
return nil
case DeliveryUnknown:
return NewAppError(http.StatusConflict, "delivery_unknown", "reward result is unknown; contact support for reconciliation")
case DeliveryProcessing, DeliveryPending:
// 已经抢占的领取由同一 owner/item 幂等边界继续处理,重复 requestId 不会创建第二份奖励。
case DeliveryNotClaimed:
now := time.Now()
if err := tx.Model(&model.YumiGiftChallengeUserTaskDaily{}).Where("id = ?", task.ID).
Updates(map[string]any{"delivery_status": DeliveryPending, "claim_request_id": requestID, "claimed_time": now, "update_time": now}).Error; err != nil {
return err
}
default:
return NewAppError(http.StatusConflict, "invalid_delivery_status", "task reward state is invalid")
}
return s.createDeliveryItemsTx(tx, OwnerTask, task.ID, activity.ID, user.UserID, task.ResourceGroupID)
})
if err != nil {
return nil, err
}
if ownerID > 0 {
if err := s.deliverOwnerItems(ctx, OwnerTask, ownerID); err != nil {
return nil, err
}
}
return s.buildUserState(ctx, *activity, statDate, user.UserID)
}
func (s *Service) buildUserState(ctx context.Context, activity model.YumiGiftChallengeActivity, statDate string, userID int64) (*UserStateResponse, error) {
var total model.YumiGiftChallengeUserScore
if err := s.db.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activity.ID, userID).First(&total).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var daily model.YumiGiftChallengeUserDailyScore
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).First(&daily).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
overallRank, err := s.currentUserRank(ctx, activity.ID, nil, userID, total.Score, total.ScoreReachedTime)
if err != nil {
return nil, err
}
dailyRank, err := s.currentUserRank(ctx, activity.ID, &statDate, userID, daily.Score, daily.ScoreReachedTime)
if err != nil {
return nil, err
}
var tasks []model.YumiGiftChallengeUserTaskDaily
if err := s.db.WithContext(ctx).Where("activity_id = ? AND stat_date = ? AND user_id = ?", activity.ID, statDate, userID).
Order("sort_order ASC, id ASC").Find(&tasks).Error; err != nil {
return nil, err
}
snapshots, err := s.loadRewardSnapshots(ctx, activity.ID)
if err != nil {
return nil, err
}
views := make([]UserTaskView, 0, len(tasks))
for _, row := range tasks {
items := []RewardItemView{}
if row.ResourceGroupID != nil {
items = rewardItemViews(snapshots[*row.ResourceGroupID])
}
views = append(views, UserTaskView{
ID: row.ID, TaskConfigID: row.TaskConfigID, StatDate: row.StatDate,
TaskCode: row.TaskCode, TaskType: row.TaskType, TaskTitle: row.TaskTitle, SortOrder: row.SortOrder,
TargetValue: row.TargetValue, ProgressValue: row.ProgressValue,
Completed: row.CompletedTime != nil, CompletedTime: millis(row.CompletedTime), ResourceGroupID: row.ResourceGroupID,
DeliveryStatus: row.DeliveryStatus, ClaimedTime: millis(row.ClaimedTime), DeliverTime: millis(row.DeliverTime), RewardItems: items,
})
}
return &UserStateResponse{
ActivityID: activity.ID, StatDate: statDate,
TotalScore: total.Score, DailyScore: daily.Score, OverallRank: overallRank, DailyRank: dailyRank, Tasks: views,
}, nil
}
func (s *Service) currentUserRank(ctx context.Context, activityID int64, statDate *string, userID int64, score model.Decimal24_2, reached time.Time) (*int, error) {
if !score.Positive() || reached.IsZero() {
return nil, nil
}
var count int64
query := s.db.WithContext(ctx)
if statDate == nil {
query = query.Model(&model.YumiGiftChallengeUserScore{}).Where("activity_id = ?", activityID)
} else {
query = query.Model(&model.YumiGiftChallengeUserDailyScore{}).Where("activity_id = ? AND stat_date = ?", activityID, *statDate)
}
err := query.Where("score > ? OR (score = ? AND score_reached_time < ?) OR (score = ? AND score_reached_time = ? AND user_id < ?)",
score, score, reached, score, reached, userID).Count(&count).Error
if err != nil {
return nil, err
}
rank := int(count) + 1
return &rank, nil
}