diff --git a/internal/api/task_center/admin.go b/internal/api/task_center/admin.go index de5c632..dd659fe 100755 --- a/internal/api/task_center/admin.go +++ b/internal/api/task_center/admin.go @@ -3,12 +3,17 @@ package taskcenter import ( "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" + "bindbox-game/internal/repository/mysql/model" tasksvc "bindbox-game/internal/service/task_center" + "encoding/json" + "fmt" "net/http" "strconv" + "strings" "time" "gorm.io/datatypes" + "gorm.io/gorm" ) // @Summary 任务列表(Admin) @@ -429,6 +434,121 @@ type rewardLogItem struct { CreatedAt string `json:"created_at"` } +type taskRewardCostSummaryItem struct { + RewardType string `json:"reward_type"` + RewardLabel string `json:"reward_label"` + GrantCount int64 `json:"grant_count"` + Quantity int64 `json:"quantity"` + TotalCost int64 `json:"total_cost"` +} + +type taskRewardCostCategoryItem struct { + RewardType string `json:"reward_type"` + RewardLabel string `json:"reward_label"` + ItemKey string `json:"item_key"` + ItemName string `json:"item_name"` + UnitCost int64 `json:"unit_cost"` + GrantCount int64 `json:"grant_count"` + Quantity int64 `json:"quantity"` + TotalCost int64 `json:"total_cost"` +} + +type taskRewardCostLogItem struct { + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + TaskName string `json:"task_name"` + UserID int64 `json:"user_id"` + Nickname string `json:"nickname"` + RewardType string `json:"reward_type"` + RewardLabel string `json:"reward_label"` + ItemName string `json:"item_name"` + Quantity int64 `json:"quantity"` + UnitCost int64 `json:"unit_cost"` + TotalCost int64 `json:"total_cost"` + CreatedAt string `json:"created_at"` +} + +type taskRewardCostStatsResponse struct { + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + TotalGrant int64 `json:"total_grant"` + TotalUsers int64 `json:"total_users"` + TotalCost int64 `json:"total_cost"` + Summaries []taskRewardCostSummaryItem `json:"summaries"` + Categories []taskRewardCostCategoryItem `json:"categories"` + Logs []taskRewardCostLogItem `json:"logs"` +} + +func rewardTypeLabel(rewardType string) string { + switch rewardType { + case "points": + return "积分" + case "coupon": + return "优惠券" + case "item_card": + return "道具卡" + case "title": + return "称号" + case "game_ticket": + return "游戏券" + case "product": + return "商品" + default: + return "未知" + } +} + +func parseDateRange(ctx core.Context) (*time.Time, *time.Time, string, string, error) { + startDate := strings.TrimSpace(ctx.Request().URL.Query().Get("start_date")) + endDate := strings.TrimSpace(ctx.Request().URL.Query().Get("end_date")) + loc := time.Local + var startTime *time.Time + var endTime *time.Time + + if startDate != "" { + t, err := time.ParseInLocation("2006-01-02", startDate, loc) + if err != nil { + return nil, nil, "", "", fmt.Errorf("开始日期格式错误") + } + startTime = &t + } else { + t := time.Now().AddDate(0, 0, -6) + t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc) + startTime = &t + startDate = t.Format("2006-01-02") + } + + if endDate != "" { + t, err := time.ParseInLocation("2006-01-02", endDate, loc) + if err != nil { + return nil, nil, "", "", fmt.Errorf("结束日期格式错误") + } + t = t.Add(24*time.Hour - time.Second) + endTime = &t + } else { + t := time.Now() + t = time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 0, loc) + endTime = &t + endDate = t.Format("2006-01-02") + } + + if startTime != nil && endTime != nil && startTime.After(*endTime) { + return nil, nil, "", "", fmt.Errorf("开始日期不能晚于结束日期") + } + + return startTime, endTime, startDate, endDate, nil +} + +func applyRewardCostDateRange(db *gorm.DB, startTime, endTime *time.Time) *gorm.DB { + if startTime != nil { + db = db.Where("el.created_at >= ?", *startTime) + } + if endTime != nil { + db = db.Where("el.created_at <= ?", *endTime) + } + return db +} + // GetTaskRewardStats 获取任务奖励发放统计 // @Summary 获取任务奖励发放统计(Admin) // @Description 获取指定任务已发放奖励的按类型统计及详细记录 @@ -449,12 +569,10 @@ func (h *handler) GetTaskRewardStats() core.HandlerFunc { db := h.repo.GetDbR() rsp := &rewardStatsResponse{TaskID: taskID} - // 1. 统计总领取人次 (去重 user_id) var totalClaim int64 db.Raw("SELECT COUNT(DISTINCT user_id) FROM task_center_event_logs WHERE task_id = ?", taskID).Scan(&totalClaim) rsp.TotalClaim = totalClaim - // 2. 按奖励类型统计 type statRow struct { RewardType string Cnt int64 @@ -479,7 +597,6 @@ func (h *handler) GetTaskRewardStats() core.HandlerFunc { } rsp.Stats = stats - // 3. 最近的发放记录 (最多100条) type logRow struct { ID int64 UserID int64 @@ -519,3 +636,336 @@ func (h *handler) GetTaskRewardStats() core.HandlerFunc { ctx.Payload(rsp) } } + +// GetRewardCostStats 获取任务奖励成本汇总统计 +// @Summary 获取任务奖励成本汇总统计(Admin) +// @Description 获取任务中心奖励发放成本汇总,支持日期筛选与分类明细 +// @Tags TaskCenter(Admin) +// @Accept json +// @Produce json +// @Param start_date query string false "开始日期 YYYY-MM-DD" +// @Param end_date query string false "结束日期 YYYY-MM-DD" +// @Success 200 {object} taskRewardCostStatsResponse "奖励成本统计" +// @Router /admin/task_center/reward-cost-stats [get] +func (h *handler) GetRewardCostStats() core.HandlerFunc { + return func(ctx core.Context) { + startTime, endTime, startDate, endDate, err := parseDateRange(ctx) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error())) + return + } + + db := h.repo.GetDbR() + rsp := &taskRewardCostStatsResponse{ + StartDate: startDate, + EndDate: endDate, + } + + baseQuery := db.Table(model.TableNameTaskCenterEventLogs+" AS el"). + Joins("LEFT JOIN "+model.TableNameUsers+" AS u ON u.id = el.user_id"). + Joins("LEFT JOIN "+model.TableNameTaskCenterTaskRewards+" AS tr ON tr.task_id = el.task_id AND tr.tier_id = el.tier_id"). + Joins("LEFT JOIN "+model.TableNameTaskCenterTasks+" AS tt ON tt.id = el.task_id") + baseQuery = applyRewardCostDateRange(baseQuery, startTime, endTime) + + var overview struct { + TotalGrant int64 + TotalUsers int64 + } + if err := baseQuery.Session(&gorm.Session{}). + Select("COUNT(el.id) AS total_grant, COUNT(DISTINCT el.user_id) AS total_users"). + Scan(&overview).Error; err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) + return + } + rsp.TotalGrant = overview.TotalGrant + rsp.TotalUsers = overview.TotalUsers + + type rewardCostRow struct { + TaskID int64 + TaskName string + ID int64 + UserID int64 + Nickname string + RewardType string + RewardPayload string + Quantity int64 + CreatedAt time.Time + } + var rewardRows []rewardCostRow + if err := baseQuery.Session(&gorm.Session{}). + Select(` + el.id, + el.task_id, + COALESCE(tt.name, '') AS task_name, + el.user_id, + COALESCE(u.nickname, '') AS nickname, + COALESCE(tr.reward_type, '') AS reward_type, + COALESCE(tr.reward_payload, '{}') AS reward_payload, + COALESCE(tr.quantity, 0) AS quantity, + el.created_at + `). + Order("el.id DESC"). + Scan(&rewardRows).Error; err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error())) + return + } + + type couponPayload struct { + CouponID int64 `json:"coupon_id"` + } + type itemCardPayload struct { + CardID int64 `json:"card_id"` + } + type titlePayload struct { + TitleID int64 `json:"title_id"` + } + type productPayload struct { + ProductID int64 `json:"product_id"` + } + + couponIDs := make([]int64, 0) + itemCardIDs := make([]int64, 0) + titleIDs := make([]int64, 0) + productIDs := make([]int64, 0) + couponSet := map[int64]struct{}{} + itemCardSet := map[int64]struct{}{} + titleSet := map[int64]struct{}{} + productSet := map[int64]struct{}{} + + for _, row := range rewardRows { + switch row.RewardType { + case "coupon": + var pl couponPayload + _ = json.Unmarshal([]byte(row.RewardPayload), &pl) + if pl.CouponID > 0 { + if _, ok := couponSet[pl.CouponID]; !ok { + couponSet[pl.CouponID] = struct{}{} + couponIDs = append(couponIDs, pl.CouponID) + } + } + case "item_card": + var pl itemCardPayload + _ = json.Unmarshal([]byte(row.RewardPayload), &pl) + if pl.CardID > 0 { + if _, ok := itemCardSet[pl.CardID]; !ok { + itemCardSet[pl.CardID] = struct{}{} + itemCardIDs = append(itemCardIDs, pl.CardID) + } + } + case "title": + var pl titlePayload + _ = json.Unmarshal([]byte(row.RewardPayload), &pl) + if pl.TitleID > 0 { + if _, ok := titleSet[pl.TitleID]; !ok { + titleSet[pl.TitleID] = struct{}{} + titleIDs = append(titleIDs, pl.TitleID) + } + } + case "product": + var pl productPayload + _ = json.Unmarshal([]byte(row.RewardPayload), &pl) + if pl.ProductID > 0 { + if _, ok := productSet[pl.ProductID]; !ok { + productSet[pl.ProductID] = struct{}{} + productIDs = append(productIDs, pl.ProductID) + } + } + } + } + + couponMap := map[int64]model.SystemCoupons{} + if len(couponIDs) > 0 { + var coupons []model.SystemCoupons + if err := db.Where("id IN ?", couponIDs).Find(&coupons).Error; err == nil { + for _, item := range coupons { + couponMap[item.ID] = item + } + } + } + itemCardMap := map[int64]model.SystemItemCards{} + if len(itemCardIDs) > 0 { + var cards []model.SystemItemCards + if err := db.Where("id IN ?", itemCardIDs).Find(&cards).Error; err == nil { + for _, item := range cards { + itemCardMap[item.ID] = item + } + } + } + titleMap := map[int64]model.SystemTitles{} + if len(titleIDs) > 0 { + var titles []model.SystemTitles + if err := db.Where("id IN ?", titleIDs).Find(&titles).Error; err == nil { + for _, item := range titles { + titleMap[item.ID] = item + } + } + } + productMap := map[int64]model.Products{} + if len(productIDs) > 0 { + var products []model.Products + if err := db.Where("id IN ?", productIDs).Find(&products).Error; err == nil { + for _, item := range products { + productMap[item.ID] = item + } + } + } + + type summaryAcc struct { + grantCount int64 + quantity int64 + totalCost int64 + } + summaryMap := map[string]*summaryAcc{} + type categoryAcc struct { + rewardType string + rewardLabel string + itemKey string + itemName string + unitCost int64 + grantCount int64 + quantity int64 + totalCost int64 + } + categoryMap := map[string]*categoryAcc{} + logs := make([]taskRewardCostLogItem, 0, len(rewardRows)) + + for _, row := range rewardRows { + rewardType := row.RewardType + if rewardType == "" { + rewardType = "unknown" + } + rewardLabel := rewardTypeLabel(rewardType) + quantity := row.Quantity + if quantity <= 0 { + quantity = 0 + } + itemKey := rewardType + itemName := rewardLabel + unitCost := int64(0) + + switch rewardType { + case "points": + itemKey = "points" + itemName = "积分" + unitCost = 1 + case "coupon": + var pl couponPayload + _ = json.Unmarshal([]byte(row.RewardPayload), &pl) + if coupon, ok := couponMap[pl.CouponID]; ok { + itemKey = fmt.Sprintf("coupon:%d", coupon.ID) + itemName = coupon.Name + unitCost = coupon.DiscountValue + } else { + itemKey = fmt.Sprintf("coupon:%d", pl.CouponID) + itemName = "优惠券" + } + case "item_card": + var pl itemCardPayload + _ = json.Unmarshal([]byte(row.RewardPayload), &pl) + if card, ok := itemCardMap[pl.CardID]; ok { + itemKey = fmt.Sprintf("item_card:%d", card.ID) + itemName = card.Name + unitCost = card.Price + } else { + itemKey = fmt.Sprintf("item_card:%d", pl.CardID) + itemName = "道具卡" + } + case "title": + var pl titlePayload + _ = json.Unmarshal([]byte(row.RewardPayload), &pl) + if title, ok := titleMap[pl.TitleID]; ok { + itemKey = fmt.Sprintf("title:%d", title.ID) + itemName = title.Name + } else { + itemKey = fmt.Sprintf("title:%d", pl.TitleID) + itemName = "称号" + } + case "game_ticket": + itemKey = "game_ticket" + itemName = "游戏券" + case "product": + var pl productPayload + _ = json.Unmarshal([]byte(row.RewardPayload), &pl) + if product, ok := productMap[pl.ProductID]; ok { + itemKey = fmt.Sprintf("product:%d", product.ID) + itemName = product.Name + unitCost = product.CostPrice + } else { + itemKey = fmt.Sprintf("product:%d", pl.ProductID) + itemName = "商品" + } + } + + totalCost := unitCost * quantity + rsp.TotalCost += totalCost + + summary := summaryMap[rewardType] + if summary == nil { + summary = &summaryAcc{} + summaryMap[rewardType] = summary + } + summary.grantCount++ + summary.quantity += quantity + summary.totalCost += totalCost + + categoryKey := rewardType + ":" + itemKey + category := categoryMap[categoryKey] + if category == nil { + category = &categoryAcc{ + rewardType: rewardType, + rewardLabel: rewardLabel, + itemKey: itemKey, + itemName: itemName, + unitCost: unitCost, + } + categoryMap[categoryKey] = category + } + category.grantCount++ + category.quantity += quantity + category.totalCost += totalCost + + logs = append(logs, taskRewardCostLogItem{ + ID: row.ID, + TaskID: row.TaskID, + TaskName: row.TaskName, + UserID: row.UserID, + Nickname: row.Nickname, + RewardType: rewardType, + RewardLabel: rewardLabel, + ItemName: itemName, + Quantity: quantity, + UnitCost: unitCost, + TotalCost: totalCost, + CreatedAt: row.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + + rsp.Summaries = make([]taskRewardCostSummaryItem, 0, len(summaryMap)) + for rewardType, acc := range summaryMap { + rsp.Summaries = append(rsp.Summaries, taskRewardCostSummaryItem{ + RewardType: rewardType, + RewardLabel: rewardTypeLabel(rewardType), + GrantCount: acc.grantCount, + Quantity: acc.quantity, + TotalCost: acc.totalCost, + }) + } + + rsp.Categories = make([]taskRewardCostCategoryItem, 0, len(categoryMap)) + for _, acc := range categoryMap { + rsp.Categories = append(rsp.Categories, taskRewardCostCategoryItem{ + RewardType: acc.rewardType, + RewardLabel: acc.rewardLabel, + ItemKey: acc.itemKey, + ItemName: acc.itemName, + UnitCost: acc.unitCost, + GrantCount: acc.grantCount, + Quantity: acc.quantity, + TotalCost: acc.totalCost, + }) + } + rsp.Logs = logs + + ctx.Payload(rsp) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index f3986a6..4cb4f1e 100755 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -140,6 +140,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er adminAuthApiRouter.POST("/task-center/events/order-paid", taskCenterHandler.SimulateOrderPaid()) adminAuthApiRouter.POST("/task-center/events/invite-success", taskCenterHandler.SimulateInviteSuccess()) adminAuthApiRouter.GET("/task-center/tasks/:id/reward-stats", taskCenterHandler.GetTaskRewardStats()) + adminAuthApiRouter.GET("/task-center/reward-cost-stats", taskCenterHandler.GetRewardCostStats()) // 工作台 adminAuthApiRouter.GET("/dashboard/cards", adminHandler.DashboardCards())