404 lines
14 KiB
Go
404 lines
14 KiB
Go
package sevendaycheckin
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"google.golang.org/grpc"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
domain "hyapp/services/activity-service/internal/domain/sevendaycheckin"
|
||
)
|
||
|
||
const (
|
||
grantSource = "seven_day_checkin"
|
||
grantReason = "seven_day_checkin"
|
||
)
|
||
|
||
// Repository 是七日签到的唯一持久化边界;连续天数和 claim 幂等都必须在 activity MySQL。
|
||
type Repository interface {
|
||
GetSevenDayCheckInConfig(ctx context.Context) (domain.Config, bool, error)
|
||
GetSevenDayCheckInRewardsForVersion(ctx context.Context, version int64) ([]domain.Reward, error)
|
||
UpdateSevenDayCheckInConfig(ctx context.Context, config domain.Config, nowMS int64) (domain.Config, error)
|
||
GetSevenDayCheckInAccount(ctx context.Context, userID int64) (domain.Account, bool, error)
|
||
GetSevenDayCheckInClaimByDay(ctx context.Context, userID int64, checkinDay string) (domain.Claim, bool, error)
|
||
ListSevenDayCheckInClaimsForUserCycle(ctx context.Context, userID int64, cycleNo int64) ([]domain.Claim, error)
|
||
PrepareSevenDayCheckInClaim(ctx context.Context, userID int64, commandID string, checkinDay string, yesterday string, nowMS int64) (domain.PrepareResult, error)
|
||
MarkSevenDayCheckInClaimGranted(ctx context.Context, claimID string, walletGrantID string, grantedAtMS int64) (domain.Claim, error)
|
||
MarkSevenDayCheckInClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
|
||
ListSevenDayCheckInClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error)
|
||
}
|
||
|
||
// WalletClient 是签到奖励的唯一外部副作用;资源组展开和钱包资产入账由 wallet-service 保证原子。
|
||
type WalletClient interface {
|
||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
|
||
}
|
||
|
||
// Service 承载七日签到 App 查询、签到命令和后台配置管理用例。
|
||
type Service struct {
|
||
repository Repository
|
||
wallet WalletClient
|
||
now func() time.Time
|
||
}
|
||
|
||
func New(repository Repository, wallet WalletClient) *Service {
|
||
return &Service{repository: repository, wallet: wallet, now: time.Now}
|
||
}
|
||
|
||
func (s *Service) SetClock(now func() time.Time) {
|
||
if now != nil {
|
||
s.now = now
|
||
}
|
||
}
|
||
|
||
// GetStatus 只读配置、用户账户和本轮 claims,不扫描钱包流水或资源赠送记录。
|
||
func (s *Service) GetStatus(ctx context.Context, userID int64) (domain.StatusResult, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
if userID <= 0 {
|
||
return domain.StatusResult{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||
}
|
||
now := s.now()
|
||
cycle := dailyCycleOf(now)
|
||
base := domain.StatusResult{
|
||
Reason: domain.ReasonNotConfigured,
|
||
CheckinDay: cycle.Key,
|
||
ServerTimeMS: now.UnixMilli(),
|
||
NextRefreshMS: cycle.NextRefreshMS,
|
||
}
|
||
config, exists, err := s.repository.GetSevenDayCheckInConfig(ctx)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
if !exists {
|
||
return base, nil
|
||
}
|
||
|
||
account, accountExists, err := s.repository.GetSevenDayCheckInAccount(ctx, userID)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
todayClaim, todaySigned, err := s.repository.GetSevenDayCheckInClaimByDay(ctx, userID, cycle.Key)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
next := nextSlot(account, accountExists, config.ConfigVersion, cycle.Key, cycle.YesterdayKey)
|
||
displayVersion := next.ConfigVersion
|
||
displayCycle := next.CycleNo
|
||
if todaySigned {
|
||
displayVersion = todayClaim.ConfigVersion
|
||
displayCycle = todayClaim.CycleNo
|
||
}
|
||
rewards, err := s.rewardsForVersion(ctx, config, displayVersion)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
claims, err := s.repository.ListSevenDayCheckInClaimsForUserCycle(ctx, userID, displayCycle)
|
||
if err != nil {
|
||
return domain.StatusResult{}, err
|
||
}
|
||
|
||
result := base
|
||
result.Enabled = config.Enabled
|
||
result.ConfigVersion = displayVersion
|
||
result.CycleNo = displayCycle
|
||
result.CurrentDayIndex = currentDayIndex(account, accountExists, cycle.Key, cycle.YesterdayKey)
|
||
result.NextDayIndex = next.DayIndex
|
||
result.LastCheckinDay = account.LastCheckinDay
|
||
result.Rewards = rewardStatuses(rewards, claims, result.NextDayIndex)
|
||
result.CanSign, result.Reason = signEligibility(config, exists, rewards, todayClaim, todaySigned)
|
||
return result, nil
|
||
}
|
||
|
||
// Sign 创建或复用当天签到 claim,只有 wallet-service 资源组发放成功后才推进连续签到账号。
|
||
func (s *Service) Sign(ctx context.Context, userID int64, commandID string) (domain.Claim, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Claim{}, err
|
||
}
|
||
commandID = strings.TrimSpace(commandID)
|
||
if userID <= 0 || commandID == "" {
|
||
return domain.Claim{}, xerr.New(xerr.InvalidArgument, "checkin command is incomplete")
|
||
}
|
||
if s.wallet == nil {
|
||
return domain.Claim{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
now := s.now()
|
||
cycle := dailyCycleOf(now)
|
||
prepared, err := s.repository.PrepareSevenDayCheckInClaim(ctx, userID, commandID, cycle.Key, cycle.YesterdayKey, now.UnixMilli())
|
||
if err != nil {
|
||
return domain.Claim{}, err
|
||
}
|
||
if !prepared.Prepared {
|
||
if prepared.Claim.ClaimID != "" {
|
||
return prepared.Claim, nil
|
||
}
|
||
return domain.Claim{}, checkinUnavailableError(prepared.Reason)
|
||
}
|
||
if prepared.Claim.Status == domain.StatusGranted {
|
||
return prepared.Claim, nil
|
||
}
|
||
|
||
claim := prepared.Claim
|
||
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||
CommandId: claim.WalletCommandID,
|
||
AppCode: appcode.FromContext(ctx),
|
||
TargetUserId: claim.UserID,
|
||
GroupId: claim.ResourceGroupID,
|
||
Reason: grantReason,
|
||
OperatorUserId: claim.UserID,
|
||
GrantSource: grantSource,
|
||
})
|
||
if err != nil {
|
||
// 账户不在失败分支推进;同一 command_id 重试会复用 claim 和 wallet_command_id。
|
||
_ = s.repository.MarkSevenDayCheckInClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
|
||
return domain.Claim{}, err
|
||
}
|
||
walletGrantID := ""
|
||
if resp.GetGrant() != nil {
|
||
walletGrantID = resp.GetGrant().GetGrantId()
|
||
}
|
||
return s.repository.MarkSevenDayCheckInClaimGranted(ctx, claim.ClaimID, walletGrantID, s.now().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) GetConfig(ctx context.Context) (domain.Config, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Config{}, err
|
||
}
|
||
config, exists, err := s.repository.GetSevenDayCheckInConfig(ctx)
|
||
if err != nil {
|
||
return domain.Config{}, err
|
||
}
|
||
if !exists {
|
||
return domain.Config{AppCode: appcode.FromContext(ctx), Rewards: defaultRewards()}, nil
|
||
}
|
||
return config, nil
|
||
}
|
||
|
||
func (s *Service) UpdateConfig(ctx context.Context, config domain.Config) (domain.Config, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return domain.Config{}, err
|
||
}
|
||
config.AppCode = appcode.FromContext(ctx)
|
||
config.Rewards = normalizeRewards(config.Rewards)
|
||
if err := validateConfig(config); err != nil {
|
||
return domain.Config{}, err
|
||
}
|
||
return s.repository.UpdateSevenDayCheckInConfig(ctx, config, s.now().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) ListClaims(ctx context.Context, query domain.ClaimQuery) ([]domain.Claim, int64, error) {
|
||
if err := s.requireRepository(); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
query.Status = strings.TrimSpace(query.Status)
|
||
query.CheckinDay = strings.TrimSpace(query.CheckinDay)
|
||
if query.DayIndex < 0 || query.DayIndex > domain.DayCount {
|
||
return nil, 0, xerr.New(xerr.InvalidArgument, "day_index is invalid")
|
||
}
|
||
if query.Page <= 0 {
|
||
query.Page = 1
|
||
}
|
||
if query.PageSize <= 0 {
|
||
query.PageSize = 20
|
||
}
|
||
if query.PageSize > 100 {
|
||
query.PageSize = 100
|
||
}
|
||
return s.repository.ListSevenDayCheckInClaims(ctx, query)
|
||
}
|
||
|
||
func (s *Service) requireRepository() error {
|
||
if s == nil || s.repository == nil {
|
||
return xerr.New(xerr.Unavailable, "seven day checkin repository is not configured")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) rewardsForVersion(ctx context.Context, config domain.Config, version int64) ([]domain.Reward, error) {
|
||
if version == config.ConfigVersion {
|
||
return config.Rewards, nil
|
||
}
|
||
rewards, err := s.repository.GetSevenDayCheckInRewardsForVersion(ctx, version)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return rewards, nil
|
||
}
|
||
|
||
type dailyCycle struct {
|
||
Key string
|
||
YesterdayKey string
|
||
NextRefreshMS int64
|
||
}
|
||
|
||
func dailyCycleOf(t time.Time) dailyCycle {
|
||
utc := t.UTC()
|
||
start := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC)
|
||
return dailyCycle{
|
||
Key: start.Format("2006-01-02"),
|
||
YesterdayKey: start.AddDate(0, 0, -1).Format("2006-01-02"),
|
||
NextRefreshMS: start.AddDate(0, 0, 1).UnixMilli(),
|
||
}
|
||
}
|
||
|
||
type slot struct {
|
||
CycleNo int64
|
||
DayIndex int32
|
||
ConfigVersion int64
|
||
}
|
||
|
||
func nextSlot(account domain.Account, exists bool, currentVersion int64, today string, yesterday string) slot {
|
||
if !exists || account.UserID <= 0 {
|
||
return slot{CycleNo: 1, DayIndex: 1, ConfigVersion: currentVersion}
|
||
}
|
||
if account.LastCheckinDay == today {
|
||
return slot{CycleNo: account.CycleNo, DayIndex: account.LastDayIndex, ConfigVersion: account.ConfigVersion}
|
||
}
|
||
if account.LastCheckinDay == yesterday && account.LastDayIndex > 0 && account.LastDayIndex < domain.DayCount {
|
||
return slot{CycleNo: account.CycleNo, DayIndex: account.LastDayIndex + 1, ConfigVersion: account.ConfigVersion}
|
||
}
|
||
nextCycle := account.CycleNo + 1
|
||
if nextCycle <= 0 {
|
||
nextCycle = 1
|
||
}
|
||
return slot{CycleNo: nextCycle, DayIndex: 1, ConfigVersion: currentVersion}
|
||
}
|
||
|
||
func currentDayIndex(account domain.Account, exists bool, today string, yesterday string) int32 {
|
||
if !exists || account.UserID <= 0 {
|
||
return 0
|
||
}
|
||
if account.LastCheckinDay == today || account.LastCheckinDay == yesterday {
|
||
return account.LastDayIndex
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func signEligibility(config domain.Config, exists bool, rewards []domain.Reward, todayClaim domain.Claim, todaySigned bool) (bool, string) {
|
||
if !exists || len(rewards) != domain.DayCount {
|
||
return false, domain.ReasonNotConfigured
|
||
}
|
||
if !config.Enabled {
|
||
return false, domain.ReasonDisabled
|
||
}
|
||
if !validRewards(rewards) {
|
||
return false, domain.ReasonInvalidConfig
|
||
}
|
||
if todaySigned {
|
||
switch todayClaim.Status {
|
||
case domain.StatusGranted:
|
||
return false, domain.ReasonAlreadySigned
|
||
case domain.StatusPending, domain.StatusFailed:
|
||
return false, domain.ReasonPendingReward
|
||
default:
|
||
return false, domain.ReasonAlreadySigned
|
||
}
|
||
}
|
||
return true, domain.ReasonEligible
|
||
}
|
||
|
||
func rewardStatuses(rewards []domain.Reward, claims []domain.Claim, nextDay int32) []domain.RewardStatus {
|
||
claimsByDay := make(map[int32]domain.Claim, len(claims))
|
||
for _, claim := range claims {
|
||
claimsByDay[claim.DayIndex] = claim
|
||
}
|
||
items := make([]domain.RewardStatus, 0, len(rewards))
|
||
for _, reward := range rewards {
|
||
status := domain.RewardStatusLocked
|
||
if reward.DayIndex == nextDay {
|
||
status = domain.RewardStatusAvailable
|
||
}
|
||
item := domain.RewardStatus{Reward: reward, Status: status}
|
||
if claim, ok := claimsByDay[reward.DayIndex]; ok {
|
||
item.ClaimID = claim.ClaimID
|
||
item.WalletGrantID = claim.WalletGrantID
|
||
item.GrantedAtMS = claim.GrantedAtMS
|
||
switch claim.Status {
|
||
case domain.StatusGranted:
|
||
item.Status = domain.RewardStatusGranted
|
||
case domain.StatusPending:
|
||
item.Status = domain.RewardStatusPending
|
||
case domain.StatusFailed:
|
||
item.Status = domain.RewardStatusFailed
|
||
}
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
sort.SliceStable(items, func(i, j int) bool { return items[i].DayIndex < items[j].DayIndex })
|
||
return items
|
||
}
|
||
|
||
func normalizeRewards(rewards []domain.Reward) []domain.Reward {
|
||
items := make([]domain.Reward, 0, len(rewards))
|
||
for _, reward := range rewards {
|
||
items = append(items, domain.Reward{DayIndex: reward.DayIndex, ResourceGroupID: reward.ResourceGroupID})
|
||
}
|
||
sort.SliceStable(items, func(i, j int) bool { return items[i].DayIndex < items[j].DayIndex })
|
||
return items
|
||
}
|
||
|
||
func validateConfig(config domain.Config) error {
|
||
if config.UpdatedByAdminID <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "operator_admin_id is required")
|
||
}
|
||
if len(config.Rewards) != domain.DayCount {
|
||
return xerr.New(xerr.InvalidArgument, "seven rewards are required")
|
||
}
|
||
seen := make(map[int32]struct{}, domain.DayCount)
|
||
for _, reward := range config.Rewards {
|
||
if reward.DayIndex < 1 || reward.DayIndex > domain.DayCount || reward.ResourceGroupID <= 0 {
|
||
return xerr.New(xerr.InvalidArgument, "checkin reward is invalid")
|
||
}
|
||
if _, ok := seen[reward.DayIndex]; ok {
|
||
return xerr.New(xerr.InvalidArgument, "checkin reward day is duplicated")
|
||
}
|
||
seen[reward.DayIndex] = struct{}{}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validRewards(rewards []domain.Reward) bool {
|
||
if len(rewards) != domain.DayCount {
|
||
return false
|
||
}
|
||
seen := make(map[int32]struct{}, domain.DayCount)
|
||
for _, reward := range rewards {
|
||
if reward.DayIndex < 1 || reward.DayIndex > domain.DayCount || reward.ResourceGroupID <= 0 {
|
||
return false
|
||
}
|
||
if _, ok := seen[reward.DayIndex]; ok {
|
||
return false
|
||
}
|
||
seen[reward.DayIndex] = struct{}{}
|
||
}
|
||
return len(seen) == domain.DayCount
|
||
}
|
||
|
||
func checkinUnavailableError(reason string) error {
|
||
switch reason {
|
||
case domain.ReasonInvalidConfig:
|
||
return xerr.New(xerr.InvalidArgument, "seven day checkin rewards are invalid")
|
||
case domain.ReasonDisabled:
|
||
return xerr.New(xerr.RuleNotActive, "seven day checkin is disabled")
|
||
default:
|
||
return xerr.New(xerr.RuleNotActive, "seven day checkin is not configured")
|
||
}
|
||
}
|
||
|
||
func defaultRewards() []domain.Reward {
|
||
rewards := make([]domain.Reward, 0, domain.DayCount)
|
||
for day := int32(1); day <= domain.DayCount; day++ {
|
||
rewards = append(rewards, domain.Reward{DayIndex: day})
|
||
}
|
||
return rewards
|
||
}
|
||
|
||
func WalletCommandID(claimID string) string {
|
||
return fmt.Sprintf("wcheckin_%s", claimID)
|
||
}
|