348 lines
15 KiB
Go
348 lines
15 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")
|
||
}
|
||
var effectiveActivity model.YumiGiftChallengeActivity
|
||
var statDate string
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
locked, lockedDate, err := lockOngoingActivityTx(tx, activity.ID, origin, time.Now())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
effectiveActivity, statDate = locked, lockedDate
|
||
var header model.YumiGiftChallengePeriodSettlement
|
||
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where(
|
||
"activity_id = ? AND period_type = ? AND period_key = ?", effectiveActivity.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, effectiveActivity, 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",
|
||
effectiveActivity.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, effectiveActivity, statDate, user.UserID)
|
||
}
|
||
|
||
// lockOngoingActivityTx 把活动时间/时区读取放到 activity SHARE 锁内。在线配置编辑会先取
|
||
// activity UPDATE 锁再重建周期门闩,因此任务事务不会把旧时区计算出的日期写进新门闩。
|
||
func lockOngoingActivityTx(tx *gorm.DB, activityID int64, origin string, now time.Time) (model.YumiGiftChallengeActivity, string, error) {
|
||
activity, err := lockEnabledActivityTx(tx, activityID, origin)
|
||
if err != nil {
|
||
return model.YumiGiftChallengeActivity{}, "", err
|
||
}
|
||
if activityStatus(activity, now) != "ONGOING" {
|
||
return model.YumiGiftChallengeActivity{}, "", NewAppError(http.StatusConflict, "activity_not_ongoing", "activity is not ongoing")
|
||
}
|
||
location, err := resolveLocation(activity.Timezone)
|
||
if err != nil {
|
||
return model.YumiGiftChallengeActivity{}, "", err
|
||
}
|
||
return activity, dateKey(now, location), nil
|
||
}
|
||
|
||
// lockEnabledActivityTx 是用户任务链路的统一首锁;历史任务领取不要求当前
|
||
// 时间窗仍 ongoing,但仍必须在锁内校验活动归属与启用状态。
|
||
func lockEnabledActivityTx(tx *gorm.DB, activityID int64, origin string) (model.YumiGiftChallengeActivity, error) {
|
||
var activity model.YumiGiftChallengeActivity
|
||
err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("id = ?", activityID).First(&activity).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return model.YumiGiftChallengeActivity{}, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
|
||
}
|
||
if err != nil {
|
||
return model.YumiGiftChallengeActivity{}, err
|
||
}
|
||
if activity.SysOrigin != origin || !activity.Enabled {
|
||
return model.YumiGiftChallengeActivity{}, NewAppError(http.StatusNotFound, "activity_not_found", "activity not found")
|
||
}
|
||
return activity, nil
|
||
}
|
||
|
||
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
|
||
}
|
||
var ownerID int64
|
||
var effectiveActivity model.YumiGiftChallengeActivity
|
||
var statDate string
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
locked, err := lockEnabledActivityTx(tx, activity.ID, origin)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
effectiveActivity = locked
|
||
location, err := resolveLocation(locked.Timezone)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
statDate = dateKey(time.Now(), location)
|
||
task, selected, err := lockClaimRequestOwnerTx(tx, effectiveActivity.ID, user.UserID, taskCode, requestID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if selected {
|
||
statDate = task.StatDate
|
||
}
|
||
if !selected && activityStatus(effectiveActivity, time.Now()) == "ONGOING" {
|
||
if err := s.ensureTaskSnapshotsTx(tx, effectiveActivity, statDate, user.UserID); err != nil {
|
||
return err
|
||
}
|
||
queryErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where("activity_id = ? AND stat_date = ? AND user_id = ? AND task_code = ?", effectiveActivity.ID, statDate, user.UserID, taskCode).
|
||
First(&task).Error
|
||
if queryErr != nil && !errors.Is(queryErr, gorm.ErrRecordNotFound) {
|
||
return queryErr
|
||
}
|
||
processable := task.DeliveryStatus == DeliveryNotClaimed || task.DeliveryStatus == DeliveryPending || task.DeliveryStatus == DeliveryProcessing
|
||
// SUCCESS 只能由上面的相同 requestId 命中后幂等返回;UNKNOWN 必须
|
||
// 排在所有可处理的历史 owner 之后,不能挡住其他日期的正常奖励。
|
||
selected = queryErr == nil && task.CompletedTime != nil && task.ProgressValue.Compare(task.TargetValue) >= 0 && processable
|
||
}
|
||
if !selected {
|
||
// 时区或时间窗变更后,已完成的旧日任务仍是独立奖励 owner。
|
||
// 先按 requestId 找原 owner 保证重试不会切到另一天,再领取最近的可处理任务。
|
||
task, err = lockHistoricalClaimTaskTx(tx, effectiveActivity.ID, user.UserID, taskCode)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
statDate = task.StatDate
|
||
}
|
||
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, effectiveActivity.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, effectiveActivity, statDate, user.UserID)
|
||
}
|
||
|
||
// lockClaimRequestOwnerTx 在选“当前日/最近日”前先恢复 requestId 已绑定的 owner。
|
||
// 即使请求重试时当前日任务又完成了,也不会用同一 requestId 多领一天。
|
||
func lockClaimRequestOwnerTx(tx *gorm.DB, activityID, userID int64, taskCode, requestID string) (model.YumiGiftChallengeUserTaskDaily, bool, error) {
|
||
var task model.YumiGiftChallengeUserTaskDaily
|
||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where("activity_id = ? AND user_id = ? AND task_code = ? AND claim_request_id = ? AND completed_time IS NOT NULL AND progress_value >= target_value",
|
||
activityID, userID, taskCode, requestID).
|
||
Order("stat_date DESC, id DESC").First(&task).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return model.YumiGiftChallengeUserTaskDaily{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return model.YumiGiftChallengeUserTaskDaily{}, false, err
|
||
}
|
||
return task, true, nil
|
||
}
|
||
|
||
// lockHistoricalClaimTaskTx 只在当前日任务不可领时走历史兜底。每个日任务行
|
||
// 是独立 owner;该函数只处理尚未绑定 requestId 的新请求。
|
||
func lockHistoricalClaimTaskTx(tx *gorm.DB, activityID, userID int64, taskCode string) (model.YumiGiftChallengeUserTaskDaily, error) {
|
||
base := func() *gorm.DB {
|
||
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||
Where("activity_id = ? AND user_id = ? AND task_code = ? AND completed_time IS NOT NULL AND progress_value >= target_value",
|
||
activityID, userID, taskCode)
|
||
}
|
||
var task model.YumiGiftChallengeUserTaskDaily
|
||
err := base().Where("delivery_status IN ?", []string{DeliveryNotClaimed, DeliveryPending, DeliveryProcessing}).
|
||
Order("stat_date DESC, id DESC").First(&task).Error
|
||
if err == nil {
|
||
return task, nil
|
||
}
|
||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return model.YumiGiftChallengeUserTaskDaily{}, err
|
||
}
|
||
// UNKNOWN 不能自动重发,但也不能被当成“没有奖励”后跳过;后续 switch
|
||
// 会要求人工核账。SUCCESS 只能通过上面的 requestId owner 查询返回幂等成功。
|
||
task = model.YumiGiftChallengeUserTaskDaily{}
|
||
err = base().Where("delivery_status = ?", DeliveryUnknown).
|
||
Order("stat_date DESC, id DESC").First(&task).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return model.YumiGiftChallengeUserTaskDaily{}, NewAppError(http.StatusConflict, "task_not_completed", "task is not completed")
|
||
}
|
||
if err != nil {
|
||
return model.YumiGiftChallengeUserTaskDaily{}, err
|
||
}
|
||
return task, nil
|
||
}
|
||
|
||
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
|
||
}
|