242 lines
9.6 KiB
Go
242 lines
9.6 KiB
Go
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
|
||
}
|