refactor(utils): 修复密码哈希比较逻辑错误 feat(user): 新增按状态筛选优惠券接口 docs: 添加虚拟发货与任务中心相关文档 fix(wechat): 修正Code2Session上下文传递问题 test: 补充订单折扣与积分转换测试用例 build: 更新配置文件与构建脚本 style: 清理多余的空行与注释
505 lines
15 KiB
Go
505 lines
15 KiB
Go
package taskcenter
|
|
|
|
import (
|
|
"bindbox-game/internal/pkg/logger"
|
|
"bindbox-game/internal/repository/mysql"
|
|
"bindbox-game/internal/repository/mysql/dao"
|
|
tcmodel "bindbox-game/internal/repository/mysql/task_center"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
titlesvc "bindbox-game/internal/service/title"
|
|
usersvc "bindbox-game/internal/service/user"
|
|
|
|
"gorm.io/datatypes"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type Service interface {
|
|
ListTasks(ctx context.Context, in ListTasksInput) (items []TaskItem, total int64, err error)
|
|
CreateTask(ctx context.Context, in CreateTaskInput) (int64, error)
|
|
ModifyTask(ctx context.Context, id int64, in ModifyTaskInput) error
|
|
DeleteTask(ctx context.Context, id int64) error
|
|
ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierItem, error)
|
|
UpsertTaskTiers(ctx context.Context, taskID int64, tiers []TaskTierInput) error
|
|
ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewardItem, error)
|
|
UpsertTaskRewards(ctx context.Context, taskID int64, rewards []TaskRewardInput) error
|
|
GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error)
|
|
ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error
|
|
OnOrderPaid(ctx context.Context, userID int64, orderID int64) error
|
|
OnInviteSuccess(ctx context.Context, inviterID int64, inviteeID int64) error
|
|
}
|
|
|
|
type service struct {
|
|
logger logger.CustomLogger
|
|
readDB *dao.Query
|
|
writeDB *dao.Query
|
|
repo mysql.Repo
|
|
userSvc usersvc.Service
|
|
titleSvc titlesvc.Service
|
|
}
|
|
|
|
func New(l logger.CustomLogger, db mysql.Repo) Service {
|
|
return &service{
|
|
logger: l,
|
|
readDB: dao.Use(db.GetDbR()),
|
|
writeDB: dao.Use(db.GetDbW()),
|
|
repo: db,
|
|
userSvc: usersvc.New(l, db),
|
|
titleSvc: titlesvc.New(l, db),
|
|
}
|
|
}
|
|
|
|
type ListTasksInput struct {
|
|
Page int
|
|
PageSize int
|
|
}
|
|
|
|
type TaskItem struct {
|
|
ID int64
|
|
Name string
|
|
Description string
|
|
Status int32
|
|
StartTime int64
|
|
EndTime int64
|
|
Visibility int32
|
|
Tiers []TaskTierItem
|
|
Rewards []TaskRewardItem
|
|
}
|
|
|
|
type UserProgress struct {
|
|
TaskID int64
|
|
UserID int64
|
|
OrderCount int64
|
|
InviteCount int64
|
|
FirstOrder bool
|
|
ClaimedTiers []int64
|
|
}
|
|
|
|
type CreateTaskInput struct {
|
|
Name string
|
|
Description string
|
|
Status int32
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Visibility int32
|
|
}
|
|
|
|
type ModifyTaskInput struct {
|
|
Name string
|
|
Description string
|
|
Status int32
|
|
StartTime *time.Time
|
|
EndTime *time.Time
|
|
Visibility int32
|
|
}
|
|
|
|
type TaskTierInput struct {
|
|
Metric string
|
|
Operator string
|
|
Threshold int64
|
|
Window string
|
|
Repeatable int32
|
|
Priority int32
|
|
}
|
|
|
|
type TaskTierItem struct {
|
|
ID int64 `json:"id"`
|
|
Metric string `json:"metric"`
|
|
Operator string `json:"operator"`
|
|
Threshold int64 `json:"threshold"`
|
|
Window string `json:"window"`
|
|
Repeatable int32 `json:"repeatable"`
|
|
Priority int32 `json:"priority"`
|
|
}
|
|
|
|
type TaskRewardInput struct {
|
|
TierID int64 `json:"tier_id"`
|
|
RewardType string `json:"reward_type"`
|
|
RewardPayload datatypes.JSON `json:"reward_payload"`
|
|
Quantity int64 `json:"quantity"`
|
|
}
|
|
|
|
type TaskRewardItem struct {
|
|
ID int64 `json:"id"`
|
|
TierID int64 `json:"tier_id"`
|
|
RewardType string `json:"reward_type"`
|
|
RewardPayload datatypes.JSON `json:"reward_payload"`
|
|
Quantity int64 `json:"quantity"`
|
|
}
|
|
|
|
func (s *service) ListTasks(ctx context.Context, in ListTasksInput) (items []TaskItem, total int64, err error) {
|
|
db := s.repo.GetDbR()
|
|
var rows []tcmodel.Task
|
|
q := db.Model(&tcmodel.Task{})
|
|
if in.PageSize <= 0 {
|
|
in.PageSize = 20
|
|
}
|
|
if in.Page <= 0 {
|
|
in.Page = 1
|
|
}
|
|
if err = q.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
if err = q.Offset((in.Page - 1) * in.PageSize).Limit(in.PageSize).Order("id desc").Find(&rows).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
items = make([]TaskItem, len(rows))
|
|
for i, v := range rows {
|
|
var st, et int64
|
|
if v.StartTime != nil {
|
|
st = v.StartTime.Unix()
|
|
}
|
|
if v.EndTime != nil {
|
|
et = v.EndTime.Unix()
|
|
}
|
|
items[i] = TaskItem{ID: v.ID, Name: v.Name, Description: v.Description, Status: v.Status, StartTime: st, EndTime: et, Visibility: v.Visibility}
|
|
// 填充 Tiers
|
|
if tiers, err := s.ListTaskTiers(ctx, v.ID); err == nil {
|
|
items[i].Tiers = tiers
|
|
}
|
|
// 填充 Rewards
|
|
if rewards, err := s.ListTaskRewards(ctx, v.ID); err == nil {
|
|
items[i].Rewards = rewards
|
|
}
|
|
}
|
|
return items, total, nil
|
|
}
|
|
|
|
func (s *service) GetUserProgress(ctx context.Context, userID int64, taskID int64) (*UserProgress, error) {
|
|
db := s.repo.GetDbR()
|
|
var row tcmodel.UserTaskProgress
|
|
if err := db.Where("user_id=? AND task_id=?", userID, taskID).First(&row).Error; err != nil {
|
|
return &UserProgress{TaskID: taskID, UserID: userID, ClaimedTiers: []int64{}}, nil
|
|
}
|
|
var claimed []int64
|
|
if len(row.ClaimedTiers) > 0 {
|
|
_ = json.Unmarshal([]byte(row.ClaimedTiers), &claimed)
|
|
}
|
|
return &UserProgress{TaskID: taskID, UserID: userID, OrderCount: row.OrderCount, InviteCount: row.InviteCount, FirstOrder: row.FirstOrder == 1, ClaimedTiers: claimed}, nil
|
|
}
|
|
|
|
func (s *service) ClaimTier(ctx context.Context, userID int64, taskID int64, tierID int64) error {
|
|
return s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
|
|
var p tcmodel.UserTaskProgress
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id=? AND task_id=?", userID, taskID).First(&p).Error; err != nil {
|
|
return errors.New("progress_not_found")
|
|
}
|
|
var claimed []int64
|
|
if len(p.ClaimedTiers) > 0 {
|
|
_ = json.Unmarshal([]byte(p.ClaimedTiers), &claimed)
|
|
}
|
|
for _, id := range claimed {
|
|
if id == tierID {
|
|
return nil
|
|
}
|
|
}
|
|
claimed = append(claimed, tierID)
|
|
b, _ := json.Marshal(claimed)
|
|
p.ClaimedTiers = datatypes.JSON(b)
|
|
return tx.Model(&tcmodel.UserTaskProgress{}).Where("id=?", p.ID).Update("claimed_tiers", p.ClaimedTiers).Error
|
|
})
|
|
}
|
|
|
|
func (s *service) CreateTask(ctx context.Context, in CreateTaskInput) (int64, error) {
|
|
db := s.repo.GetDbW()
|
|
row := &tcmodel.Task{Name: in.Name, Description: in.Description, Status: in.Status, StartTime: in.StartTime, EndTime: in.EndTime, Visibility: in.Visibility}
|
|
if err := db.Create(row).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
return row.ID, nil
|
|
}
|
|
|
|
func (s *service) ModifyTask(ctx context.Context, id int64, in ModifyTaskInput) error {
|
|
db := s.repo.GetDbW()
|
|
return db.Model(&tcmodel.Task{}).Where("id=?", id).Updates(map[string]any{"name": in.Name, "description": in.Description, "status": in.Status, "start_time": in.StartTime, "end_time": in.EndTime, "visibility": in.Visibility}).Error
|
|
}
|
|
|
|
func (s *service) DeleteTask(ctx context.Context, id int64) error {
|
|
db := s.repo.GetDbW()
|
|
return db.Where("id=?", id).Delete(&tcmodel.Task{}).Error
|
|
}
|
|
|
|
func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierItem, error) {
|
|
db := s.repo.GetDbR()
|
|
var rows []tcmodel.TaskTier
|
|
if err := db.Where("task_id=?", taskID).Order("priority asc, id asc").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]TaskTierItem, len(rows))
|
|
for i, v := range rows {
|
|
out[i] = TaskTierItem{ID: v.ID, Metric: v.Metric, Operator: v.Operator, Threshold: v.Threshold, Window: v.Window, Repeatable: v.Repeatable, Priority: v.Priority}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []TaskTierInput) error {
|
|
db := s.repo.GetDbW()
|
|
if err := db.Where("task_id=?", taskID).Delete(&tcmodel.TaskTier{}).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, t := range tiers {
|
|
row := &tcmodel.TaskTier{TaskID: taskID, Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority}
|
|
if err := db.Create(row).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewardItem, error) {
|
|
db := s.repo.GetDbR()
|
|
var rows []tcmodel.TaskReward
|
|
if err := db.Where("task_id=?", taskID).Order("id asc").Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]TaskRewardItem, len(rows))
|
|
for i, v := range rows {
|
|
out[i] = TaskRewardItem{ID: v.ID, TierID: v.TierID, RewardType: v.RewardType, RewardPayload: v.RewardPayload, Quantity: v.Quantity}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *service) UpsertTaskRewards(ctx context.Context, taskID int64, rewards []TaskRewardInput) error {
|
|
db := s.repo.GetDbW()
|
|
if err := db.Where("task_id=?", taskID).Delete(&tcmodel.TaskReward{}).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, r := range rewards {
|
|
row := &tcmodel.TaskReward{TaskID: taskID, TierID: r.TierID, RewardType: r.RewardType, RewardPayload: r.RewardPayload, Quantity: r.Quantity}
|
|
if err := db.Create(row).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) OnOrderPaid(ctx context.Context, userID int64, orderID int64) error {
|
|
var tasks []tcmodel.Task
|
|
if err := s.repo.GetDbR().Where("status=1").Find(&tasks).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, t := range tasks {
|
|
var p tcmodel.UserTaskProgress
|
|
err := s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id=? AND task_id=?", userID, t.ID).First(&p).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
p = tcmodel.UserTaskProgress{UserID: userID, TaskID: t.ID, OrderCount: 1, FirstOrder: 1}
|
|
return tx.Create(&p).Error
|
|
}
|
|
return err
|
|
}
|
|
if err := s.checkAndResetDailyProgress(ctx, tx, t.ID, &p); err != nil {
|
|
return err
|
|
}
|
|
p.OrderCount++
|
|
if p.OrderCount == 1 {
|
|
p.FirstOrder = 1
|
|
}
|
|
return tx.Save(&p).Error
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.matchAndGrant(ctx, &t, &p, "order", orderID, fmt.Sprintf("ord:%d", orderID)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) OnInviteSuccess(ctx context.Context, inviterID int64, inviteeID int64) error {
|
|
var tasks []tcmodel.Task
|
|
if err := s.repo.GetDbR().Where("status=1").Find(&tasks).Error; err != nil {
|
|
return err
|
|
}
|
|
for _, t := range tasks {
|
|
var p tcmodel.UserTaskProgress
|
|
err := s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id=? AND task_id=?", inviterID, t.ID).First(&p).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
p = tcmodel.UserTaskProgress{UserID: inviterID, TaskID: t.ID, InviteCount: 1}
|
|
return tx.Create(&p).Error
|
|
}
|
|
return err
|
|
}
|
|
if err := s.checkAndResetDailyProgress(ctx, tx, t.ID, &p); err != nil {
|
|
return err
|
|
}
|
|
p.InviteCount++
|
|
return tx.Save(&p).Error
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.matchAndGrant(ctx, &t, &p, "invite", inviteeID, fmt.Sprintf("inv:%d", inviteeID)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) checkAndResetDailyProgress(ctx context.Context, tx *gorm.DB, taskID int64, p *tcmodel.UserTaskProgress) error {
|
|
var count int64
|
|
if err := tx.Model(&tcmodel.TaskTier{}).Where("task_id = ? AND window = ?", taskID, "daily").Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
return nil
|
|
}
|
|
now := time.Now()
|
|
if p.UpdatedAt.IsZero() {
|
|
return nil
|
|
}
|
|
y1, m1, d1 := p.UpdatedAt.Date()
|
|
y2, m2, d2 := now.Date()
|
|
if y1 == y2 && m1 == m2 && d1 == d2 {
|
|
return nil
|
|
}
|
|
p.OrderCount = 0
|
|
p.InviteCount = 0
|
|
p.FirstOrder = 0
|
|
p.ClaimedTiers = datatypes.JSON("[]")
|
|
return nil
|
|
}
|
|
|
|
func (s *service) matchAndGrant(ctx context.Context, t *tcmodel.Task, p *tcmodel.UserTaskProgress, sourceType string, sourceID int64, eventID string) error {
|
|
var tiers []tcmodel.TaskTier
|
|
if err := s.repo.GetDbR().Where("task_id=?", t.ID).Order("priority asc").Find(&tiers).Error; err != nil {
|
|
return err
|
|
}
|
|
var claimed []int64
|
|
if len(p.ClaimedTiers) > 0 {
|
|
_ = json.Unmarshal([]byte(p.ClaimedTiers), &claimed)
|
|
}
|
|
claimedSet := map[int64]struct{}{}
|
|
for _, id := range claimed {
|
|
claimedSet[id] = struct{}{}
|
|
}
|
|
for _, tier := range tiers {
|
|
if _, ok := claimedSet[tier.ID]; ok {
|
|
continue
|
|
}
|
|
hit := false
|
|
switch tier.Metric {
|
|
case "first_order":
|
|
hit = p.FirstOrder == 1
|
|
case "order_count":
|
|
if tier.Operator == ">=" {
|
|
hit = p.OrderCount >= tier.Threshold
|
|
} else {
|
|
hit = p.OrderCount == tier.Threshold
|
|
}
|
|
case "invite_count":
|
|
if tier.Operator == ">=" {
|
|
hit = p.InviteCount >= tier.Threshold
|
|
} else {
|
|
hit = p.InviteCount == tier.Threshold
|
|
}
|
|
}
|
|
if !hit {
|
|
continue
|
|
}
|
|
if err := s.grantTierRewards(ctx, t.ID, tier.ID, p.UserID, sourceType, sourceID, eventID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 安全更新状态
|
|
err := s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
|
|
var latestP tcmodel.UserTaskProgress
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", p.ID).First(&latestP).Error; err != nil {
|
|
return err
|
|
}
|
|
var latestClaimed []int64
|
|
if len(latestP.ClaimedTiers) > 0 {
|
|
_ = json.Unmarshal([]byte(latestP.ClaimedTiers), &latestClaimed)
|
|
}
|
|
for _, id := range latestClaimed {
|
|
if id == tier.ID {
|
|
return nil
|
|
}
|
|
}
|
|
latestClaimed = append(latestClaimed, tier.ID)
|
|
b, _ := json.Marshal(latestClaimed)
|
|
latestP.ClaimedTiers = datatypes.JSON(b)
|
|
return tx.Model(&latestP).Update("claimed_tiers", latestP.ClaimedTiers).Error
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *service) grantTierRewards(ctx context.Context, taskID int64, tierID int64, userID int64, sourceType string, sourceID int64, eventID string) error {
|
|
var rewards []tcmodel.TaskReward
|
|
if err := s.repo.GetDbR().Where("task_id=? AND tier_id=?", taskID, tierID).Find(&rewards).Error; err != nil {
|
|
return err
|
|
}
|
|
idk := fmt.Sprintf("%d:%d:%d:%s:%d", userID, taskID, tierID, sourceType, sourceID)
|
|
var exists tcmodel.TaskEventLog
|
|
if err := s.repo.GetDbR().Where("idempotency_key=?", idk).First(&exists).Error; err == nil && exists.ID > 0 {
|
|
return nil
|
|
}
|
|
tx := s.repo.GetDbW().Begin()
|
|
for _, r := range rewards {
|
|
var err error
|
|
switch r.RewardType {
|
|
case "points":
|
|
var pl struct{ Points int64 }
|
|
_ = json.Unmarshal([]byte(r.RewardPayload), &pl)
|
|
if pl.Points != 0 {
|
|
err = s.userSvc.AddPoints(ctx, userID, pl.Points, "", "task_center", nil, nil)
|
|
}
|
|
case "coupon":
|
|
var pl struct {
|
|
CouponID int64
|
|
Quantity int
|
|
}
|
|
_ = json.Unmarshal([]byte(r.RewardPayload), &pl)
|
|
if pl.CouponID > 0 {
|
|
qty := 1
|
|
if pl.Quantity > 0 {
|
|
qty = pl.Quantity
|
|
}
|
|
for i := 0; i < qty; i++ {
|
|
if err = s.userSvc.AddCoupon(ctx, userID, pl.CouponID); err != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
case "item_card":
|
|
var pl struct {
|
|
CardID int64
|
|
Quantity int
|
|
}
|
|
_ = json.Unmarshal([]byte(r.RewardPayload), &pl)
|
|
if pl.CardID > 0 {
|
|
if pl.Quantity <= 0 {
|
|
pl.Quantity = 1
|
|
}
|
|
err = s.userSvc.AddItemCard(ctx, userID, pl.CardID, pl.Quantity)
|
|
}
|
|
case "title":
|
|
var pl struct{ TitleID int64 }
|
|
_ = json.Unmarshal([]byte(r.RewardPayload), &pl)
|
|
if pl.TitleID > 0 {
|
|
err = s.titleSvc.AssignUserTitle(ctx, userID, pl.TitleID, nil, "task_center")
|
|
}
|
|
}
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
}
|
|
if err := tx.Create(&tcmodel.TaskEventLog{EventID: eventID, SourceType: sourceType, SourceID: sourceID, UserID: userID, TaskID: taskID, TierID: tierID, IdempotencyKey: idk, Status: "granted"}).Error; err != nil {
|
|
tx.Rollback()
|
|
return err
|
|
}
|
|
return tx.Commit().Error
|
|
}
|