348 lines
15 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")
}
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
}