邹方成 45815bfb7d chore: 清理无用文件与优化代码结构
refactor(utils): 修复密码哈希比较逻辑错误
feat(user): 新增按状态筛选优惠券接口
docs: 添加虚拟发货与任务中心相关文档
fix(wechat): 修正Code2Session上下文传递问题
test: 补充订单折扣与积分转换测试用例
build: 更新配置文件与构建脚本
style: 清理多余的空行与注释
2025-12-18 17:35:55 +08:00

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
}