672 lines
34 KiB
Go
672 lines
34 KiB
Go
package activitytemplate
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"log/slog"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
|
||
"google.golang.org/grpc"
|
||
"google.golang.org/protobuf/proto"
|
||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/activitymq"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/idgen"
|
||
"hyapp/pkg/logx"
|
||
"hyapp/pkg/rocketmqx"
|
||
"hyapp/pkg/xerr"
|
||
domain "hyapp/services/activity-service/internal/domain/activitytemplate"
|
||
messageservice "hyapp/services/activity-service/internal/service/message"
|
||
)
|
||
|
||
const (
|
||
activityTemplateTaskRewardReason = "activity template daily task reward"
|
||
activityTemplateRankRewardReason = "activity template leaderboard reward"
|
||
activityTemplateTaskGrantSource = "activity_template_task"
|
||
activityTemplateRankGrantSource = "activity_template_rank"
|
||
)
|
||
|
||
// RuntimeRepository is intentionally separate from the configuration Repository so legacy unit-test fakes
|
||
// remain small. Production MySQL implements both and New binds them through a checked type assertion.
|
||
type RuntimeRepository interface {
|
||
FindCurrentActivityTemplateRuntime(ctx context.Context, templateCode string, regionID int64, atMS int64) (domain.Runtime, bool, error)
|
||
FindActivityTemplateRuntimeForPeriod(ctx context.Context, templateCode string, versionNo int64, regionID int64, boardType string, periodKey string, atMS int64) (domain.Runtime, bool, error)
|
||
FindActivityTemplateRuntimeForUserTask(ctx context.Context, templateCode string, versionNo int64, userID int64, taskDay string, taskKey string) (domain.Runtime, bool, error)
|
||
FindActivityTemplateRuntimeForExactLeaderboard(ctx context.Context, templateCode string, versionNo int64, boardType string, periodKey string, atMS int64) (domain.Runtime, bool, error)
|
||
GetActivityTemplateRuntimeVersion(ctx context.Context, templateID string, versionNo int64) (domain.Runtime, error)
|
||
ConsumeActivityTemplateGiftEvent(ctx context.Context, event domain.GiftEvent, nowMS int64) (domain.EventResult, error)
|
||
VisitActivityTemplate(ctx context.Context, command domain.VisitCommand, nowMS int64) (domain.VisitResult, error)
|
||
ListActivityTemplateTaskProgress(ctx context.Context, runtime domain.Runtime, userID int64, taskDay string) ([]domain.TaskProgress, error)
|
||
PrepareActivityTemplateTaskClaim(ctx context.Context, runtime domain.Runtime, command domain.ClaimCommand, nowMS int64) (domain.TaskClaim, error)
|
||
MarkActivityTemplateTaskClaimGranted(ctx context.Context, claimID string, walletGrantID string, nowMS int64) (domain.TaskClaim, error)
|
||
MarkActivityTemplateTaskClaimFailed(ctx context.Context, claimID string, reason string, nowMS int64) error
|
||
ListActivityTemplateRuntimeLeaderboard(ctx context.Context, runtime domain.Runtime, query domain.LeaderboardQuery) (domain.LeaderboardResult, error)
|
||
ClaimDueActivityTemplateRankPeriods(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.RankPeriod, error)
|
||
PrepareActivityTemplateRankSettlement(ctx context.Context, period domain.RankPeriod, nowMS int64) ([]domain.RewardJob, error)
|
||
ClaimFinalizableActivityTemplateRankPeriods(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.RankPeriod, error)
|
||
ClaimActivityTemplateRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int32) ([]domain.RewardJob, error)
|
||
ExtendActivityTemplateRewardJobLease(ctx context.Context, rewardJobID string, workerID string, nowMS int64, lockTTL time.Duration) error
|
||
MarkActivityTemplateRewardJobGrantedWithLease(ctx context.Context, rewardJobID string, workerID string, walletGrantID string, nowMS int64) error
|
||
MarkActivityTemplateRewardJobFailedWithLease(ctx context.Context, rewardJobID string, workerID string, reason string, nowMS int64) error
|
||
FinishActivityTemplateRankPeriodIfComplete(ctx context.Context, period domain.RankPeriod, nowMS int64) (bool, error)
|
||
ListActivityTemplateRewardJobs(ctx context.Context, query domain.RewardJobQuery) ([]domain.RewardJob, int64, error)
|
||
ClaimActivityTemplateRewardJobForManualRetry(ctx context.Context, templateID string, rewardJobID string, workerID string, nowMS int64, lockTTL time.Duration) (domain.RewardJob, error)
|
||
ListActivityTemplateTaskClaims(ctx context.Context, query domain.TaskClaimQuery) ([]domain.TaskClaim, int64, error)
|
||
ClaimActivityTemplateTaskClaimForRetry(ctx context.Context, templateID string, claimID string, nowMS int64) (domain.TaskClaim, error)
|
||
ClaimActivityTemplateStatsOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.StatsOutboxRecord, error)
|
||
MarkActivityTemplateStatsOutboxSent(ctx context.Context, outboxID string, nowMS int64) error
|
||
MarkActivityTemplateStatsOutboxFailed(ctx context.Context, outboxID string, reason string, nowMS int64) error
|
||
}
|
||
|
||
type RuntimeWallet interface {
|
||
GrantPinnedResourceGroup(ctx context.Context, req *walletv1.GrantPinnedResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||
}
|
||
|
||
type StatsPublisher interface {
|
||
SendSync(ctx context.Context, message rocketmqx.Message) error
|
||
}
|
||
|
||
type NoticeService interface {
|
||
CreateActivityNotice(ctx context.Context, cmd messageservice.NoticeCommand) (messageservice.NoticeResult, error)
|
||
}
|
||
|
||
// BindRuntime installs optional runtime dependencies without widening the stable configuration constructor.
|
||
// Production wiring treats a missing binding as Unavailable; configuration-only tests never call these methods.
|
||
func (s *Service) BindRuntime(repository RuntimeRepository, wallet RuntimeWallet) {
|
||
if s == nil {
|
||
return
|
||
}
|
||
s.runtime = repository
|
||
s.runtimeWallet = wallet
|
||
}
|
||
|
||
func (s *Service) BindNotice(notices NoticeService) {
|
||
if s != nil {
|
||
s.notices = notices
|
||
}
|
||
}
|
||
|
||
func (s *Service) GetCurrentRuntime(ctx context.Context, regionID int64, templateCode string) (domain.Runtime, bool, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return domain.Runtime{}, false, err
|
||
}
|
||
if regionID <= 0 {
|
||
return domain.Runtime{}, false, xerr.New(xerr.InvalidArgument, "region_id is required")
|
||
}
|
||
return s.runtime.FindCurrentActivityTemplateRuntime(ctx, strings.TrimSpace(templateCode), regionID, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) Visit(ctx context.Context, command domain.VisitCommand) (domain.VisitResult, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return domain.VisitResult{}, err
|
||
}
|
||
command.TemplateCode = strings.TrimSpace(command.TemplateCode)
|
||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||
if command.UserID <= 0 || command.RegionID <= 0 || command.VersionNo <= 0 || command.TemplateCode == "" || command.CommandID == "" || len(command.CommandID) > 128 {
|
||
return domain.VisitResult{}, xerr.New(xerr.InvalidArgument, "visit command is incomplete")
|
||
}
|
||
return s.runtime.VisitActivityTemplate(ctx, command, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) ListRuntimeTasks(ctx context.Context, userID, regionID, versionNo int64, templateCode, taskDay string) (domain.Runtime, []domain.TaskProgress, int64, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return domain.Runtime{}, nil, 0, err
|
||
}
|
||
if userID <= 0 || versionNo < 0 || strings.TrimSpace(templateCode) == "" || (versionNo == 0 && regionID <= 0) {
|
||
return domain.Runtime{}, nil, 0, xerr.New(xerr.InvalidArgument, "task query is incomplete")
|
||
}
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
var runtime domain.Runtime
|
||
var found bool
|
||
var err error
|
||
if versionNo > 0 {
|
||
if strings.TrimSpace(taskDay) == "" {
|
||
return domain.Runtime{}, nil, 0, xerr.New(xerr.InvalidArgument, "task_day is required for historical version")
|
||
}
|
||
runtime, found, err = s.runtime.FindActivityTemplateRuntimeForUserTask(ctx, strings.TrimSpace(templateCode), versionNo, userID, taskDay, "")
|
||
} else {
|
||
runtime, found, err = s.runtime.FindCurrentActivityTemplateRuntime(ctx, strings.TrimSpace(templateCode), regionID, nowMS)
|
||
}
|
||
if err != nil {
|
||
return domain.Runtime{}, nil, 0, err
|
||
}
|
||
if !found {
|
||
return domain.Runtime{}, nil, 0, xerr.New(xerr.NotFound, "active activity template not found")
|
||
}
|
||
if strings.TrimSpace(taskDay) == "" {
|
||
taskDay = utcDay(nowMS)
|
||
}
|
||
dayStart, err := parseUTCDay(taskDay)
|
||
if err != nil || dayStart >= runtime.Template.EndMS || dayStart+int64(24*time.Hour/time.Millisecond) <= runtime.RuntimeFromMS {
|
||
return domain.Runtime{}, nil, 0, xerr.New(xerr.InvalidArgument, "task_day is outside the activity runtime")
|
||
}
|
||
items, err := s.runtime.ListActivityTemplateTaskProgress(ctx, runtime, userID, taskDay)
|
||
if err != nil {
|
||
return domain.Runtime{}, nil, 0, err
|
||
}
|
||
nextRefresh := dayStart + int64(24*time.Hour/time.Millisecond)
|
||
if runtime.Template.EndMS < nextRefresh {
|
||
nextRefresh = runtime.Template.EndMS
|
||
}
|
||
return runtime, items, nextRefresh, nil
|
||
}
|
||
|
||
func (s *Service) ClaimRuntimeTaskReward(ctx context.Context, command domain.ClaimCommand) (domain.TaskClaim, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
if s.runtimeWallet == nil {
|
||
return domain.TaskClaim{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
command.TemplateCode = strings.TrimSpace(command.TemplateCode)
|
||
command.TaskDay = strings.TrimSpace(command.TaskDay)
|
||
command.TaskKey = strings.TrimSpace(command.TaskKey)
|
||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||
if command.UserID <= 0 || command.TemplateCode == "" || command.VersionNo <= 0 || command.TaskKey == "" || command.CommandID == "" || len(command.CommandID) > 128 {
|
||
return domain.TaskClaim{}, xerr.New(xerr.InvalidArgument, "task claim command is incomplete")
|
||
}
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
if command.TaskDay == "" {
|
||
command.TaskDay = utcDay(nowMS)
|
||
}
|
||
if _, err := parseUTCDay(command.TaskDay); err != nil {
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
// version_no 来自任务列表的不可变版本。即使同一 template_code 在同一个 UTC 日重发,v1 的失败
|
||
// claim 也不会被 latest-v2 吞掉;物化 period 仍允许活动结束后的幂等 wallet 补偿。
|
||
runtime, found, err := s.runtime.FindActivityTemplateRuntimeForUserTask(
|
||
ctx, command.TemplateCode, command.VersionNo, command.UserID, command.TaskDay, command.TaskKey,
|
||
)
|
||
if err != nil {
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
if !found {
|
||
return domain.TaskClaim{}, xerr.New(xerr.NotFound, "activity template task period not found")
|
||
}
|
||
claim, err := s.runtime.PrepareActivityTemplateTaskClaim(ctx, runtime, command, nowMS)
|
||
if err != nil || claim.Status == domain.ClaimStatusGranted {
|
||
return claim, err
|
||
}
|
||
if claim.RewardSnapshot.SnapshotID == "" {
|
||
err := xerr.New(xerr.Conflict, "activity task reward snapshot is missing")
|
||
_ = s.runtime.MarkActivityTemplateTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
resp, err := s.runtimeWallet.GrantPinnedResourceGroup(ctx, &walletv1.GrantPinnedResourceGroupRequest{
|
||
CommandId: claim.WalletCommandID, AppCode: appcode.FromContext(ctx), TargetUserId: claim.UserID,
|
||
SnapshotId: claim.RewardSnapshot.SnapshotID, Reason: activityTemplateTaskRewardReason,
|
||
OperatorUserId: claim.UserID, GrantSource: activityTemplateTaskGrantSource,
|
||
ExpectedSnapshotHash: claim.RewardSnapshot.SnapshotHash,
|
||
})
|
||
if err != nil {
|
||
_ = s.runtime.MarkActivityTemplateTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
walletGrantID := strings.TrimSpace(resp.GetGrant().GetGrantId())
|
||
if walletGrantID == "" {
|
||
err := xerr.New(xerr.Internal, "wallet returned an empty resource grant")
|
||
_ = s.runtime.MarkActivityTemplateTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
return s.runtime.MarkActivityTemplateTaskClaimGranted(ctx, claim.ClaimID, walletGrantID, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) ListRuntimeLeaderboard(ctx context.Context, query domain.LeaderboardQuery) (domain.LeaderboardResult, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return domain.LeaderboardResult{}, err
|
||
}
|
||
query.TemplateCode = strings.TrimSpace(query.TemplateCode)
|
||
query.BoardType = strings.ToLower(strings.TrimSpace(query.BoardType))
|
||
query.PeriodKey = strings.TrimSpace(query.PeriodKey)
|
||
if query.UserID <= 0 || query.VersionNo < 0 || query.TemplateCode == "" || (query.VersionNo == 0 && query.RegionID <= 0) || (query.BoardType != domain.BoardTypeDaily && query.BoardType != domain.BoardTypeTotal) {
|
||
return domain.LeaderboardResult{}, xerr.New(xerr.InvalidArgument, "leaderboard query is invalid")
|
||
}
|
||
query.NowMS = s.now().UTC().UnixMilli()
|
||
var runtime domain.Runtime
|
||
var found bool
|
||
var err error
|
||
// 显式 period_key 是可分享、可从获奖通知回跳的稳定榜单身份。先按物化 period 解析其不可变发布版本,
|
||
// 因此活动结束或同一模版中途重发后仍能读取冻结榜;未显式指定时继续只暴露当前运行版本。
|
||
if query.VersionNo > 0 && query.PeriodKey == "" {
|
||
if query.BoardType == domain.BoardTypeTotal {
|
||
query.PeriodKey = "total"
|
||
} else {
|
||
query.PeriodKey = utcDay(query.NowMS)
|
||
}
|
||
}
|
||
if query.PeriodKey != "" && query.VersionNo > 0 {
|
||
runtime, found, err = s.runtime.FindActivityTemplateRuntimeForExactLeaderboard(
|
||
ctx, query.TemplateCode, query.VersionNo, query.BoardType, query.PeriodKey, query.NowMS,
|
||
)
|
||
} else if query.PeriodKey != "" {
|
||
runtime, found, err = s.runtime.FindActivityTemplateRuntimeForPeriod(
|
||
ctx, query.TemplateCode, query.VersionNo, query.RegionID, query.BoardType, query.PeriodKey, query.NowMS,
|
||
)
|
||
} else {
|
||
runtime, found, err = s.runtime.FindCurrentActivityTemplateRuntime(ctx, query.TemplateCode, query.RegionID, query.NowMS)
|
||
}
|
||
if err != nil {
|
||
return domain.LeaderboardResult{}, err
|
||
}
|
||
if !found {
|
||
return domain.LeaderboardResult{}, xerr.New(xerr.NotFound, "active activity template not found")
|
||
}
|
||
return s.runtime.ListActivityTemplateRuntimeLeaderboard(ctx, runtime, query)
|
||
}
|
||
|
||
func (s *Service) AdminListRuntimeLeaderboard(ctx context.Context, query domain.LeaderboardQuery) (domain.LeaderboardResult, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return domain.LeaderboardResult{}, err
|
||
}
|
||
query.TemplateID = strings.TrimSpace(query.TemplateID)
|
||
query.BoardType = strings.ToLower(strings.TrimSpace(query.BoardType))
|
||
if query.TemplateID == "" || query.VersionNo <= 0 || (query.BoardType != domain.BoardTypeDaily && query.BoardType != domain.BoardTypeTotal) {
|
||
return domain.LeaderboardResult{}, xerr.New(xerr.InvalidArgument, "admin leaderboard query is invalid")
|
||
}
|
||
query.NowMS = s.now().UTC().UnixMilli()
|
||
runtime, err := s.runtime.GetActivityTemplateRuntimeVersion(ctx, query.TemplateID, query.VersionNo)
|
||
if err != nil {
|
||
return domain.LeaderboardResult{}, err
|
||
}
|
||
return s.runtime.ListActivityTemplateRuntimeLeaderboard(ctx, runtime, query)
|
||
}
|
||
|
||
func (s *Service) ListRewardJobs(ctx context.Context, query domain.RewardJobQuery) ([]domain.RewardJob, int64, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
query.TemplateID = strings.TrimSpace(query.TemplateID)
|
||
query.BoardType = strings.ToLower(strings.TrimSpace(query.BoardType))
|
||
query.PeriodKey = strings.TrimSpace(query.PeriodKey)
|
||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||
if query.TemplateID == "" {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "template_id is required")
|
||
}
|
||
if query.BoardType != "" && query.BoardType != domain.BoardTypeDaily && query.BoardType != domain.BoardTypeTotal {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "board_type is invalid")
|
||
}
|
||
if query.Status != "" && query.Status != domain.ClaimStatusPending && query.Status != domain.ClaimStatusRunning && query.Status != domain.ClaimStatusGranted && query.Status != domain.ClaimStatusFailed && query.Status != domain.ClaimStatusDead {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "reward status is invalid")
|
||
}
|
||
return s.runtime.ListActivityTemplateRewardJobs(ctx, query)
|
||
}
|
||
|
||
func (s *Service) ListTaskClaims(ctx context.Context, query domain.TaskClaimQuery) ([]domain.TaskClaim, int64, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
query.TemplateID = strings.TrimSpace(query.TemplateID)
|
||
query.TaskDay = strings.TrimSpace(query.TaskDay)
|
||
query.TaskKey = strings.TrimSpace(query.TaskKey)
|
||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||
if query.TemplateID == "" || query.VersionNo < 0 || query.UserID < 0 {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "task claim query is invalid")
|
||
}
|
||
if query.TaskDay != "" {
|
||
if _, err := parseUTCDay(query.TaskDay); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
}
|
||
if query.Status != "" && query.Status != domain.ClaimStatusPending && query.Status != domain.ClaimStatusRunning && query.Status != domain.ClaimStatusGranted && query.Status != domain.ClaimStatusFailed {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "task claim status is invalid")
|
||
}
|
||
return s.runtime.ListActivityTemplateTaskClaims(ctx, query)
|
||
}
|
||
|
||
func (s *Service) RetryTaskClaim(ctx context.Context, templateID, claimID string, operatorAdminID int64) (domain.TaskClaim, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
if s.runtimeWallet == nil {
|
||
return domain.TaskClaim{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
if strings.TrimSpace(templateID) == "" || strings.TrimSpace(claimID) == "" || operatorAdminID <= 0 {
|
||
return domain.TaskClaim{}, xerr.New(xerr.InvalidArgument, "task claim retry command is incomplete")
|
||
}
|
||
claim, err := s.runtime.ClaimActivityTemplateTaskClaimForRetry(ctx, strings.TrimSpace(templateID), strings.TrimSpace(claimID), s.now().UTC().UnixMilli())
|
||
if err != nil {
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
if claim.RewardSnapshot.SnapshotID == "" || !validSHA256Hex(claim.RewardSnapshot.SnapshotHash) {
|
||
err := xerr.New(xerr.Conflict, "activity task reward snapshot is missing")
|
||
_ = s.runtime.MarkActivityTemplateTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
resp, err := s.runtimeWallet.GrantPinnedResourceGroup(ctx, &walletv1.GrantPinnedResourceGroupRequest{
|
||
CommandId: claim.WalletCommandID, AppCode: appcode.FromContext(ctx), TargetUserId: claim.UserID,
|
||
SnapshotId: claim.RewardSnapshot.SnapshotID, ExpectedSnapshotHash: claim.RewardSnapshot.SnapshotHash,
|
||
Reason: activityTemplateTaskRewardReason, OperatorUserId: claim.UserID, GrantSource: activityTemplateTaskGrantSource,
|
||
})
|
||
if err != nil {
|
||
_ = s.runtime.MarkActivityTemplateTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
walletGrantID := strings.TrimSpace(resp.GetGrant().GetGrantId())
|
||
if walletGrantID == "" {
|
||
err := xerr.New(xerr.Internal, "wallet returned an empty resource grant")
|
||
_ = s.runtime.MarkActivityTemplateTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return domain.TaskClaim{}, err
|
||
}
|
||
return s.runtime.MarkActivityTemplateTaskClaimGranted(ctx, claim.ClaimID, walletGrantID, s.now().UTC().UnixMilli())
|
||
}
|
||
|
||
// ProcessRuntimeSettlementBatch separates bounded period materialization from bounded job delivery. A large
|
||
// leaderboard can therefore never make one cron RPC perform unbounded wallet calls; every delivery is fenced by
|
||
// a per-invocation lease owner and remains replay-safe through the immutable wallet command.
|
||
func (s *Service) ProcessRuntimeSettlementBatch(ctx context.Context, runID, workerID string, batchSize int32, lockTTL time.Duration) (claimed, processed, success, failure int32, hasMore bool, err error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return 0, 0, 0, 0, false, err
|
||
}
|
||
if s.runtimeWallet == nil {
|
||
return 0, 0, 0, 0, false, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
if batchSize <= 0 {
|
||
batchSize = 20
|
||
}
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
workerID = strings.TrimSpace(workerID)
|
||
if workerID == "" {
|
||
workerID = "activity-template-settlement"
|
||
}
|
||
runID = strings.TrimSpace(runID)
|
||
if runID == "" {
|
||
runID = idgen.New("run")
|
||
}
|
||
leaseOwner := activityTemplateRewardLeaseOwner(workerID, runID)
|
||
periods, err := s.runtime.ClaimDueActivityTemplateRankPeriods(ctx, leaseOwner, nowMS, lockTTL, batchSize)
|
||
if err != nil {
|
||
return 0, 0, 0, 0, false, err
|
||
}
|
||
for _, period := range periods {
|
||
_, prepareErr := s.runtime.PrepareActivityTemplateRankSettlement(ctx, period, s.now().UTC().UnixMilli())
|
||
if prepareErr != nil {
|
||
logx.Error(ctx, "activity_template_prepare_settlement_failed", prepareErr, slog.String("template_id", period.TemplateID), slog.String("period_key", period.PeriodKey), slog.String("run_id", runID))
|
||
failure++
|
||
continue
|
||
}
|
||
// Empty/no-reward boards settle here; boards with jobs are released from the period lock and
|
||
// finish after the independent job batch reaches all-granted.
|
||
if _, finishErr := s.runtime.FinishActivityTemplateRankPeriodIfComplete(ctx, period, s.now().UTC().UnixMilli()); finishErr != nil {
|
||
failure++
|
||
}
|
||
}
|
||
|
||
jobs, err := s.runtime.ClaimActivityTemplateRewardJobs(ctx, leaseOwner, s.now().UTC().UnixMilli(), lockTTL, batchSize)
|
||
if err != nil {
|
||
return 0, 0, 0, failure, len(periods) == int(batchSize), err
|
||
}
|
||
claimed = int32(len(jobs))
|
||
processed = int32(len(jobs))
|
||
var succeeded atomic.Int32
|
||
var failed atomic.Int32
|
||
workerCount := min(len(jobs), 8)
|
||
if workerCount > 0 {
|
||
queue := make(chan domain.RewardJob)
|
||
var workers sync.WaitGroup
|
||
workers.Add(workerCount)
|
||
for range workerCount {
|
||
go func() {
|
||
defer workers.Done()
|
||
for job := range queue {
|
||
if _, grantErr := s.grantRuntimeRewardJob(ctx, job, leaseOwner, lockTTL); grantErr != nil {
|
||
failed.Add(1)
|
||
continue
|
||
}
|
||
succeeded.Add(1)
|
||
}
|
||
}()
|
||
}
|
||
for _, job := range jobs {
|
||
queue <- job
|
||
}
|
||
close(queue)
|
||
workers.Wait()
|
||
}
|
||
success = succeeded.Load()
|
||
failure += failed.Load()
|
||
|
||
// A single job batch can span periods. Finalize each identity once after every wallet worker has stopped,
|
||
// so a period is never marked settled while an in-process job still owns a lease.
|
||
periodsToFinish := make(map[string]domain.RankPeriod)
|
||
for _, job := range jobs {
|
||
key := job.TemplateID + "\x00" + strconv.FormatInt(job.VersionNo, 10) + "\x00" + job.BoardType + "\x00" + job.PeriodKey
|
||
periodsToFinish[key] = domain.RankPeriod{
|
||
AppCode: job.AppCode, TemplateID: job.TemplateID, TemplateCode: job.TemplateCode, VersionNo: job.VersionNo,
|
||
BoardType: job.BoardType, PeriodKey: job.PeriodKey,
|
||
}
|
||
}
|
||
for _, period := range periodsToFinish {
|
||
if _, finishErr := s.runtime.FinishActivityTemplateRankPeriodIfComplete(ctx, period, s.now().UTC().UnixMilli()); finishErr != nil {
|
||
failure++
|
||
}
|
||
}
|
||
|
||
// Recover the narrow crash window after the last granted-job commit but before its period transition. The
|
||
// repository only returns `rewarding` periods with zero non-granted jobs, so failed/dead/backoff queues cannot
|
||
// consume this bound or keep cron in a has_more hot loop.
|
||
finalizable, finalizeClaimErr := s.runtime.ClaimFinalizableActivityTemplateRankPeriods(
|
||
ctx, leaseOwner, s.now().UTC().UnixMilli(), lockTTL, batchSize,
|
||
)
|
||
if finalizeClaimErr != nil {
|
||
return claimed, processed, success, failure, len(periods) == int(batchSize) || len(jobs) == int(batchSize), finalizeClaimErr
|
||
}
|
||
for _, period := range finalizable {
|
||
if _, finishErr := s.runtime.FinishActivityTemplateRankPeriodIfComplete(ctx, period, s.now().UTC().UnixMilli()); finishErr != nil {
|
||
failure++
|
||
}
|
||
}
|
||
hasMore = len(periods) == int(batchSize) || len(jobs) == int(batchSize) || len(finalizable) == int(batchSize)
|
||
return claimed, processed, success, failure, hasMore, nil
|
||
}
|
||
|
||
func (s *Service) RetryRewardJob(ctx context.Context, templateID, rewardJobID string, operatorAdminID int64) (domain.RewardJob, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return domain.RewardJob{}, err
|
||
}
|
||
if s.runtimeWallet == nil {
|
||
return domain.RewardJob{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
if strings.TrimSpace(templateID) == "" || strings.TrimSpace(rewardJobID) == "" || operatorAdminID <= 0 {
|
||
return domain.RewardJob{}, xerr.New(xerr.InvalidArgument, "reward retry command is incomplete")
|
||
}
|
||
nowMS := s.now().UTC().UnixMilli()
|
||
leaseOwner := activityTemplateRewardLeaseOwner("admin-retry-"+strconv.FormatInt(operatorAdminID, 10), idgen.New("request")+":"+rewardJobID)
|
||
const manualRetryLeaseTTL = time.Minute
|
||
job, err := s.runtime.ClaimActivityTemplateRewardJobForManualRetry(
|
||
ctx, strings.TrimSpace(templateID), strings.TrimSpace(rewardJobID), leaseOwner, nowMS, manualRetryLeaseTTL,
|
||
)
|
||
if err != nil || job.Status == domain.ClaimStatusGranted {
|
||
return job, err
|
||
}
|
||
walletGrantID, err := s.grantRuntimeRewardJob(ctx, job, leaseOwner, manualRetryLeaseTTL)
|
||
if err != nil {
|
||
return domain.RewardJob{}, err
|
||
}
|
||
job.Status = domain.ClaimStatusGranted
|
||
job.WalletGrantID = walletGrantID
|
||
job.FailureReason = ""
|
||
job.GrantedAtMS = s.now().UTC().UnixMilli()
|
||
job.UpdatedAtMS = job.GrantedAtMS
|
||
return job, nil
|
||
}
|
||
|
||
func (s *Service) grantRuntimeRewardJob(ctx context.Context, job domain.RewardJob, leaseOwner string, lockTTL time.Duration) (string, error) {
|
||
markFailed := func(cause error) error {
|
||
if markErr := s.runtime.MarkActivityTemplateRewardJobFailedWithLease(ctx, job.RewardJobID, leaseOwner, xerr.MessageOf(cause), s.now().UTC().UnixMilli()); markErr != nil {
|
||
logx.Error(ctx, "activity_template_reward_job_failure_fence_lost", markErr,
|
||
slog.String("reward_job_id", job.RewardJobID), slog.String("lease_owner", leaseOwner))
|
||
}
|
||
return cause
|
||
}
|
||
if job.RewardSnapshot.SnapshotID == "" || !validSHA256Hex(job.RewardSnapshot.SnapshotHash) {
|
||
err := xerr.New(xerr.Conflict, "activity rank reward snapshot is missing")
|
||
return "", markFailed(err)
|
||
}
|
||
if err := s.runtime.ExtendActivityTemplateRewardJobLease(ctx, job.RewardJobID, leaseOwner, s.now().UTC().UnixMilli(), lockTTL); err != nil {
|
||
return "", err
|
||
}
|
||
resp, err := s.runtimeWallet.GrantPinnedResourceGroup(ctx, &walletv1.GrantPinnedResourceGroupRequest{
|
||
CommandId: job.WalletCommandID, AppCode: appcode.FromContext(ctx), TargetUserId: job.UserID,
|
||
SnapshotId: job.RewardSnapshot.SnapshotID, Reason: activityTemplateRankRewardReason,
|
||
OperatorUserId: job.UserID, GrantSource: activityTemplateRankGrantSource,
|
||
ExpectedSnapshotHash: job.RewardSnapshot.SnapshotHash,
|
||
})
|
||
if err != nil {
|
||
return "", markFailed(err)
|
||
}
|
||
walletGrantID := strings.TrimSpace(resp.GetGrant().GetGrantId())
|
||
if walletGrantID == "" {
|
||
err := xerr.New(xerr.Internal, "wallet returned an empty resource grant")
|
||
return "", markFailed(err)
|
||
}
|
||
// The wallet call can outlive the original lease window. Re-fence before emitting the user notice; if
|
||
// another worker took over, it will replay the same wallet command and stable notice event instead.
|
||
if err := s.runtime.ExtendActivityTemplateRewardJobLease(ctx, job.RewardJobID, leaseOwner, s.now().UTC().UnixMilli(), lockTTL); err != nil {
|
||
return "", err
|
||
}
|
||
if s.notices != nil {
|
||
action := url.Values{}
|
||
action.Set("template_code", job.TemplateCode)
|
||
action.Set("version_no", strconv.FormatInt(job.VersionNo, 10))
|
||
action.Set("board_type", job.BoardType)
|
||
action.Set("period_key", job.PeriodKey)
|
||
_, noticeErr := s.notices.CreateActivityNotice(ctx, messageservice.NoticeCommand{
|
||
TargetUserID: job.UserID, Producer: "activity-template-runtime",
|
||
ProducerEventID: job.RewardJobID + ":granted", ProducerEventType: "ActivityTemplateRankRewardGranted",
|
||
AggregateType: "activity_template_reward_job", AggregateID: job.RewardJobID,
|
||
TemplateID: job.TemplateID, TemplateVersion: strconv.FormatInt(job.VersionNo, 10),
|
||
// Inbox storage currently requires title/summary text. Persist stable content keys rather than
|
||
// English prose; App clients localize the keys with the immutable metadata parameters below.
|
||
Title: "activity_template.rank_reward.title", Summary: "activity_template.rank_reward.summary",
|
||
ActionType: "activity_detail",
|
||
ActionParam: action.Encode(), SentAtMS: s.now().UTC().UnixMilli(),
|
||
Metadata: map[string]any{
|
||
"content_key": "activity_template.rank_reward_granted", "template_code": job.TemplateCode,
|
||
"version_no": job.VersionNo, "board_type": job.BoardType, "period_key": job.PeriodKey,
|
||
"rank_no": job.RankNo, "reward_name": job.RewardSnapshot.Name,
|
||
"resource_group_id": job.ResourceGroupID,
|
||
},
|
||
})
|
||
if noticeErr != nil {
|
||
// Wallet command is stable. Marking failed makes the next cron/admin retry replay the same wallet
|
||
// grant (idempotent) and retry the inbox write instead of silently losing the winner notification.
|
||
return "", markFailed(noticeErr)
|
||
}
|
||
}
|
||
if err := s.runtime.MarkActivityTemplateRewardJobGrantedWithLease(ctx, job.RewardJobID, leaseOwner, walletGrantID, s.now().UTC().UnixMilli()); err != nil {
|
||
// 钱包与站内信均已幂等成功但本地状态落库失败时,立即释放结算周期并标为 failed;下一轮会用同一
|
||
// wallet_command_id/producer_event_id 补偿,不会重复发奖或重复通知。
|
||
_ = s.runtime.MarkActivityTemplateRewardJobFailedWithLease(ctx, job.RewardJobID, leaseOwner, xerr.MessageOf(err), s.now().UTC().UnixMilli())
|
||
return "", err
|
||
}
|
||
return walletGrantID, nil
|
||
}
|
||
|
||
func activityTemplateRewardLeaseOwner(workerID, invocationID string) string {
|
||
workerID = strings.TrimSpace(workerID)
|
||
invocationID = strings.TrimSpace(invocationID)
|
||
owner := workerID + ":" + invocationID
|
||
if len(owner) <= 128 {
|
||
return owner
|
||
}
|
||
// Preserve an operator-readable prefix while bounding the persisted fencing token.
|
||
digest := sha256.Sum256([]byte(owner))
|
||
return "activity-template-reward-lease:" + hex.EncodeToString(digest[:])
|
||
}
|
||
|
||
// HandleRoomEvent accepts only wallet-committed real gift facts. coin_spent is the scoring source of truth;
|
||
// configured gift price is a display snapshot and is never multiplied in this path.
|
||
func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (int32, error) {
|
||
if s == nil || s.runtime == nil || envelope == nil || envelope.GetEventType() != "RoomGiftSent" {
|
||
return 0, nil
|
||
}
|
||
var gift roomeventsv1.RoomGiftSent
|
||
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
|
||
return 0, err
|
||
}
|
||
if gift.GetIsRobotGift() || gift.GetSyntheticLuckyGift() || gift.GetRealRoomRobotGift() {
|
||
return 0, nil
|
||
}
|
||
if strings.TrimSpace(envelope.GetEventId()) == "" || gift.GetSenderUserId() <= 0 || strings.TrimSpace(gift.GetGiftId()) == "" || gift.GetGiftCount() <= 0 || gift.GetCoinSpent() <= 0 || gift.GetRegionId() <= 0 || envelope.GetOccurredAtMs() <= 0 {
|
||
return 0, nil
|
||
}
|
||
result, err := s.runtime.ConsumeActivityTemplateGiftEvent(appcode.WithContext(ctx, envelope.GetAppCode()), domain.GiftEvent{
|
||
EventID: strings.TrimSpace(envelope.GetEventId()), UserID: gift.GetSenderUserId(), GiftID: strings.TrimSpace(gift.GetGiftId()),
|
||
// region_id is the sender's profile region captured by room-service at wallet commit. visible_region_id
|
||
// describes the room audience and must never choose a user-targeted activity runtime.
|
||
GiftCount: int64(gift.GetGiftCount()), CoinAmount: gift.GetCoinSpent(), RegionID: gift.GetRegionId(),
|
||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||
}, s.now().UTC().UnixMilli())
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
// Normal misses (no active runtime / gift not configured) are expected routing outcomes. Closed rank
|
||
// watermarks are operationally different: a committed gift arrived too late for at least one projection,
|
||
// so surface both partial acceptance and full rejection with delay metadata for alerting.
|
||
if strings.Contains(result.SkipReason, "settlement_window_closed") || strings.Contains(result.SkipReason, "rank_period_closed") {
|
||
logx.Warn(ctx, "activity_template_gift_rank_projection_late",
|
||
slog.String("event_id", result.EventID), slog.String("template_id", result.TemplateID),
|
||
slog.Int64("version_no", result.VersionNo), slog.String("task_day", result.TaskDay),
|
||
slog.String("projection_outcome", result.SkipReason), slog.Int64("processing_delay_ms", max(int64(0), s.now().UTC().UnixMilli()-envelope.GetOccurredAtMs())))
|
||
}
|
||
if result.Status == domain.EventStatusConsumed {
|
||
return 1, nil
|
||
}
|
||
return 0, nil
|
||
}
|
||
|
||
// ProcessStatsOutboxBatch publishes owner facts to the statistics topic. Sent is marked only after broker ack;
|
||
// a crash after ack but before MarkSent is harmless because statistics_event_consumption deduplicates event_id.
|
||
func (s *Service) ProcessStatsOutboxBatch(ctx context.Context, publisher StatsPublisher, topic, workerID string, batchSize int, lockTTL time.Duration) (int, int, error) {
|
||
if err := s.requireRuntime(); err != nil {
|
||
return 0, 0, err
|
||
}
|
||
if publisher == nil || strings.TrimSpace(topic) == "" {
|
||
return 0, 0, xerr.New(xerr.Unavailable, "activity template stats publisher is not configured")
|
||
}
|
||
items, err := s.runtime.ClaimActivityTemplateStatsOutbox(ctx, workerID, s.now().UTC().UnixMilli(), lockTTL, batchSize)
|
||
if err != nil {
|
||
return 0, 0, err
|
||
}
|
||
success := 0
|
||
for _, item := range items {
|
||
tag, tagErr := activitymq.EventTypeTag(item.EventType)
|
||
if tagErr == nil {
|
||
tagErr = publisher.SendSync(ctx, rocketmqx.Message{Topic: topic, Tag: tag, Keys: []string{item.OutboxID}, Body: item.Payload})
|
||
}
|
||
if tagErr != nil {
|
||
_ = s.runtime.MarkActivityTemplateStatsOutboxFailed(ctx, item.OutboxID, tagErr.Error(), s.now().UTC().UnixMilli())
|
||
continue
|
||
}
|
||
if err := s.runtime.MarkActivityTemplateStatsOutboxSent(ctx, item.OutboxID, s.now().UTC().UnixMilli()); err != nil {
|
||
return len(items), success, err
|
||
}
|
||
success++
|
||
}
|
||
return len(items), success, nil
|
||
}
|
||
|
||
func (s *Service) requireRuntime() error {
|
||
if s == nil || s.runtime == nil {
|
||
return xerr.New(xerr.Unavailable, "activity template runtime repository is not configured")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func utcDay(ms int64) string { return time.UnixMilli(ms).UTC().Format("2006-01-02") }
|
||
|
||
func parseUTCDay(value string) (int64, error) {
|
||
t, err := time.Parse("2006-01-02", strings.TrimSpace(value))
|
||
if err != nil || t.Format("2006-01-02") != strings.TrimSpace(value) {
|
||
return 0, xerr.New(xerr.InvalidArgument, "task_day must be UTC YYYY-MM-DD")
|
||
}
|
||
return t.UnixMilli(), nil
|
||
}
|