2026-07-15 09:43:33 +08:00

305 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package activitytemplate
import (
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/statisticsclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
activityv1 "hyapp.local/api/proto/activity/v1"
"github.com/gin-gonic/gin"
)
const maxActivityTemplateDataRange = 366 * 24 * time.Hour
func (h *Handler) Leaderboard(c *gin.Context) {
options := shared.ListOptions(c)
versionNo, ok := positiveInt64Query(c, "version_no", true)
if !ok {
response.BadRequest(c, "version_no 参数不正确")
return
}
boardType := strings.ToLower(strings.TrimSpace(c.Query("board_type")))
periodKey := strings.TrimSpace(c.Query("period_key"))
if boardType != "daily" && boardType != "total" {
response.BadRequest(c, "board_type 参数不正确")
return
}
if boardType == "daily" {
if parsed, err := time.Parse("2006-01-02", periodKey); err != nil || parsed.Format("2006-01-02") != periodKey {
response.BadRequest(c, "daily 榜单必须提供 YYYY-MM-DD 格式的 period_key")
return
}
} else {
periodKey = "total"
}
resp, err := h.activity.ListActivityTemplateLeaderboard(c.Request.Context(), &activityv1.AdminListActivityTemplateLeaderboardRequest{
Meta: h.meta(c), TemplateId: strings.TrimSpace(c.Param("template_id")), VersionNo: versionNo,
BoardType: boardType, PeriodKey: periodKey, Page: int32(options.Page), PageSize: int32(options.PageSize),
})
if err != nil {
h.writeGRPCError(c, err, "获取活动模版榜单失败")
return
}
entries := make([]leaderboardEntryDTO, 0, len(resp.GetEntries()))
users := h.activityTemplateUsers(c, leaderboardProtoUserIDs(resp.GetEntries()))
for _, item := range resp.GetEntries() {
entries = append(entries, leaderboardEntryFromProto(item, users))
}
response.OK(c, leaderboardPageDTO{
Period: rankPeriodFromProto(resp.GetPeriod()), Items: entries,
Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal(),
})
}
func (h *Handler) RewardJobs(c *gin.Context) {
options := shared.ListOptions(c)
versionNo, ok := positiveInt64Query(c, "version_no", true)
if !ok {
response.BadRequest(c, "version_no 参数不正确")
return
}
boardType := strings.ToLower(strings.TrimSpace(c.Query("board_type")))
if boardType != "" && boardType != "daily" && boardType != "total" {
response.BadRequest(c, "board_type 参数不正确")
return
}
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
if status != "" && status != "pending" && status != "running" && status != "granted" && status != "failed" && status != "dead" {
response.BadRequest(c, "status 参数不正确")
return
}
periodKey := strings.TrimSpace(c.Query("period_key"))
if boardType == "total" && periodKey != "" {
periodKey = "total"
}
resp, err := h.activity.ListActivityTemplateRewardJobs(c.Request.Context(), &activityv1.ListActivityTemplateRewardJobsRequest{
Meta: h.meta(c), TemplateId: strings.TrimSpace(c.Param("template_id")), VersionNo: versionNo,
BoardType: boardType, PeriodKey: periodKey, Status: status, Page: int32(options.Page), PageSize: int32(options.PageSize),
})
if err != nil {
h.writeGRPCError(c, err, "获取活动模版奖励发放记录失败")
return
}
items := make([]rewardJobDTO, 0, len(resp.GetJobs()))
users := h.activityTemplateUsers(c, rewardJobProtoUserIDs(resp.GetJobs()))
for _, item := range resp.GetJobs() {
items = append(items, rewardJobFromProto(item, users))
}
response.OK(c, pageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) RetryRewardJob(c *gin.Context) {
templateID := strings.TrimSpace(c.Param("template_id"))
jobID := strings.TrimSpace(c.Param("reward_job_id"))
if templateID == "" || jobID == "" {
response.BadRequest(c, "活动模版或奖励任务 ID 不正确")
return
}
resp, err := h.activity.RetryActivityTemplateRewardJob(c.Request.Context(), &activityv1.RetryActivityTemplateRewardJobRequest{
Meta: h.meta(c), TemplateId: templateID, RewardJobId: jobID, OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
h.writeGRPCError(c, err, "重试活动模版奖励发放失败")
return
}
users := h.activityTemplateUsers(c, []int64{resp.GetJob().GetUserId()})
item := rewardJobFromProto(resp.GetJob(), users)
shared.OperationLogWithResourceID(c, h.audit, "retry-activity-template-reward", "activity_template_reward_jobs", jobID, "success", "")
response.OK(c, item)
}
func (h *Handler) TaskClaims(c *gin.Context) {
options := shared.ListOptions(c)
versionNo, versionOK := positiveInt64Query(c, "version_no", false)
userID, userOK := positiveInt64Query(c, "user_id", false)
if !versionOK || !userOK {
response.BadRequest(c, "version_no 或 user_id 参数不正确")
return
}
taskDay := strings.TrimSpace(c.Query("task_day"))
if taskDay != "" {
parsed, err := time.Parse("2006-01-02", taskDay)
if err != nil || parsed.Format("2006-01-02") != taskDay {
response.BadRequest(c, "task_day 必须为 YYYY-MM-DD")
return
}
}
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
if status != "" && status != "pending" && status != "running" && status != "granted" && status != "failed" {
response.BadRequest(c, "status 参数不正确")
return
}
resp, err := h.activity.ListActivityTemplateTaskClaims(c.Request.Context(), &activityv1.ListActivityTemplateTaskClaimsRequest{
Meta: h.meta(c), TemplateId: strings.TrimSpace(c.Param("template_id")), VersionNo: versionNo,
TaskDay: taskDay, TaskKey: strings.TrimSpace(c.Query("task_key")), UserId: userID, Status: status,
Page: int32(options.Page), PageSize: int32(options.PageSize),
})
if err != nil {
h.writeGRPCError(c, err, "获取活动模版任务领奖记录失败")
return
}
items := make([]taskClaimDTO, 0, len(resp.GetClaims()))
users := h.activityTemplateUsers(c, taskClaimProtoUserIDs(resp.GetClaims()))
for _, item := range resp.GetClaims() {
items = append(items, taskClaimFromProto(item, users))
}
response.OK(c, pageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) RetryTaskClaim(c *gin.Context) {
templateID := strings.TrimSpace(c.Param("template_id"))
claimID := strings.TrimSpace(c.Param("claim_id"))
if templateID == "" || claimID == "" {
response.BadRequest(c, "活动模版或任务领奖记录 ID 不正确")
return
}
resp, err := h.activity.RetryActivityTemplateTaskClaim(c.Request.Context(), &activityv1.RetryActivityTemplateTaskClaimRequest{
Meta: h.meta(c), TemplateId: templateID, ClaimId: claimID, OperatorAdminId: int64(middleware.CurrentUserID(c)),
})
if err != nil {
h.writeGRPCError(c, err, "重试活动模版任务奖励发放失败")
return
}
users := h.activityTemplateUsers(c, []int64{resp.GetClaim().GetUserId()})
item := taskClaimFromProto(resp.GetClaim(), users)
shared.OperationLogWithResourceID(c, h.audit, "retry-activity-template-task-claim", "activity_template_task_claims", claimID, "success", "")
response.OK(c, item)
}
func (h *Handler) activityTemplateUsers(c *gin.Context, userIDs []int64) map[int64]*userclient.User {
if h.users == nil || len(userIDs) == 0 {
return nil
}
unique := make([]int64, 0, len(userIDs))
seen := make(map[int64]struct{}, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
continue
}
if _, exists := seen[userID]; exists {
continue
}
seen[userID] = struct{}{}
unique = append(unique, userID)
}
if len(unique) == 0 {
return nil
}
users, err := h.users.BatchGetUsers(c.Request.Context(), userclient.BatchGetUsersRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", UserIDs: unique,
})
if err != nil {
// 用户资料是展示装饰,榜单和发奖事实仍必须返回;不能因 user-service 短暂不可用隐藏结算结果。
return nil
}
return users
}
func leaderboardProtoUserIDs(items []*activityv1.ActivityTemplateLeaderboardEntry) []int64 {
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item != nil {
userIDs = append(userIDs, item.GetUserId())
}
}
return userIDs
}
func rewardJobProtoUserIDs(items []*activityv1.ActivityTemplateRewardJob) []int64 {
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item != nil {
userIDs = append(userIDs, item.GetUserId())
}
}
return userIDs
}
func taskClaimProtoUserIDs(items []*activityv1.ActivityTemplateTaskClaim) []int64 {
userIDs := make([]int64, 0, len(items))
for _, item := range items {
if item != nil {
userIDs = append(userIDs, item.GetUserId())
}
}
return userIDs
}
func (h *Handler) Data(c *gin.Context) {
if h.stats == nil {
response.ServerError(c, "活动模版统计服务未配置")
return
}
versionNo, ok := positiveInt64Query(c, "version_no", true)
if !ok {
response.BadRequest(c, "version_no 参数不正确")
return
}
startMS, startOK := requiredPositiveInt64Query(c, "start_ms")
endMS, endOK := requiredPositiveInt64Query(c, "end_ms")
if !startOK || !endOK || endMS <= startMS {
response.BadRequest(c, "start_ms 或 end_ms 参数不正确")
return
}
regionID := int64(0)
if raw, exists := c.GetQuery("region_id"); exists {
var err error
regionID, err = strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err != nil || regionID <= 0 {
response.BadRequest(c, "region_id 参数不正确")
return
}
}
// statistics-service 按 UTC 日聚合。把任意 UI 时间窗归一为覆盖它的完整 [UTC 00:00, UTC 00:00)
// 既避免半日过滤产生“看似精确但实际丢整日”的静默误差,也由响应 start_ms/end_ms 明确回显生效窗口。
effectiveStartMS := utcDayFloor(startMS)
effectiveEndMS := utcDayCeil(endMS)
if effectiveEndMS-effectiveStartMS > maxActivityTemplateDataRange.Milliseconds() {
response.BadRequest(c, "活动模版数据查询区间不能超过 366 天")
return
}
data, err := h.stats.ActivityTemplateData(c.Request.Context(), statisticsclient.ActivityTemplateDataQuery{
AppCode: appctx.FromContext(c.Request.Context()), TemplateID: strings.TrimSpace(c.Param("template_id")),
PublishedVersion: versionNo, RegionID: regionID, StartMS: effectiveStartMS, EndMS: effectiveEndMS,
})
if err != nil {
response.ServerError(c, "获取活动模版统计数据失败")
return
}
response.OK(c, data)
}
func positiveInt64Query(c *gin.Context, key string, required bool) (int64, bool) {
raw, exists := c.GetQuery(key)
if !exists || strings.TrimSpace(raw) == "" {
return 0, !required
}
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
return value, err == nil && value > 0
}
func requiredPositiveInt64Query(c *gin.Context, key string) (int64, bool) {
value, ok := positiveInt64Query(c, key, true)
return value, ok
}
func utcDayFloor(ms int64) int64 {
value := time.UnixMilli(ms).UTC()
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
}
func utcDayCeil(ms int64) int64 {
floor := utcDayFloor(ms)
if floor == ms {
return floor
}
return floor + 24*time.Hour.Milliseconds()
}